onvif-mcp-core 0.2.7__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.
- onvif_mcp_core-0.2.7/PKG-INFO +9 -0
- onvif_mcp_core-0.2.7/pyproject.toml +17 -0
- onvif_mcp_core-0.2.7/src/onvif_mcp_core/__init__.py +46 -0
- onvif_mcp_core-0.2.7/src/onvif_mcp_core/audio.py +103 -0
- onvif_mcp_core-0.2.7/src/onvif_mcp_core/camera_queries.py +226 -0
- onvif_mcp_core-0.2.7/src/onvif_mcp_core/credentials.py +24 -0
- onvif_mcp_core-0.2.7/src/onvif_mcp_core/device.py +98 -0
- onvif_mcp_core-0.2.7/src/onvif_mcp_core/guidance.py +817 -0
- onvif_mcp_core-0.2.7/src/onvif_mcp_core/ptz.py +509 -0
- onvif_mcp_core-0.2.7/src/onvif_mcp_core/streaming.py +128 -0
- onvif_mcp_core-0.2.7/src/onvif_mcp_core/tools.py +91 -0
- onvif_mcp_core-0.2.7/src/onvif_mcp_core/video.py +127 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: onvif-mcp-core
|
|
3
|
+
Version: 0.2.7
|
|
4
|
+
Summary: Shared camera operations for the ONVIF MCP servers
|
|
5
|
+
Requires-Dist: libonvif==4.0.24
|
|
6
|
+
Requires-Dist: mcp>=1.0.0
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Project-URL: Bug Reports, https://github.com/sr99622/onvif-mcp/issues
|
|
9
|
+
Project-URL: Homepage, https://github.com/sr99622/onvif-mcp
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "onvif-mcp-core"
|
|
3
|
+
version = "0.2.7"
|
|
4
|
+
description = "Shared camera operations for the ONVIF MCP servers"
|
|
5
|
+
requires-python = ">=3.10"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"libonvif==4.0.24",
|
|
8
|
+
"mcp>=1.0.0",
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
[build-system]
|
|
12
|
+
requires = ["uv_build>=0.8.0,<0.9.0"]
|
|
13
|
+
build-backend = "uv_build"
|
|
14
|
+
|
|
15
|
+
[project.urls]
|
|
16
|
+
"Homepage" = "https://github.com/sr99622/onvif-mcp"
|
|
17
|
+
"Bug Reports" = "https://github.com/sr99622/onvif-mcp/issues"
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Shared functionality for the ONVIF MCP server transports."""
|
|
2
|
+
|
|
3
|
+
from .credentials import CameraCredentials, get_camera_credentials
|
|
4
|
+
from .audio import set_camera_audio_encoding, set_camera_audio_sample_rate
|
|
5
|
+
from .camera_queries import get_adapters, get_camera, get_cameras
|
|
6
|
+
from .device import change_camera_hostname, reboot_camera, sync_camera_time
|
|
7
|
+
from .streaming import get_snapshot, get_web_player_url
|
|
8
|
+
from .tools import (
|
|
9
|
+
register_audio_configuration_tools,
|
|
10
|
+
register_camera_query_tools,
|
|
11
|
+
register_device_management_tools,
|
|
12
|
+
register_ptz_tools,
|
|
13
|
+
register_streaming_tools,
|
|
14
|
+
register_video_configuration_tools,
|
|
15
|
+
)
|
|
16
|
+
from .video import (
|
|
17
|
+
set_camera_video_bitrate,
|
|
18
|
+
set_camera_video_frame_rate,
|
|
19
|
+
set_camera_video_gov_length,
|
|
20
|
+
set_camera_video_resolution,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"CameraCredentials",
|
|
25
|
+
"get_camera_credentials",
|
|
26
|
+
"get_camera",
|
|
27
|
+
"get_cameras",
|
|
28
|
+
"get_adapters",
|
|
29
|
+
"get_web_player_url",
|
|
30
|
+
"get_snapshot",
|
|
31
|
+
"change_camera_hostname",
|
|
32
|
+
"reboot_camera",
|
|
33
|
+
"sync_camera_time",
|
|
34
|
+
"register_audio_configuration_tools",
|
|
35
|
+
"register_camera_query_tools",
|
|
36
|
+
"register_device_management_tools",
|
|
37
|
+
"register_ptz_tools",
|
|
38
|
+
"register_streaming_tools",
|
|
39
|
+
"register_video_configuration_tools",
|
|
40
|
+
"set_camera_audio_encoding",
|
|
41
|
+
"set_camera_audio_sample_rate",
|
|
42
|
+
"set_camera_video_bitrate",
|
|
43
|
+
"set_camera_video_frame_rate",
|
|
44
|
+
"set_camera_video_gov_length",
|
|
45
|
+
"set_camera_video_resolution",
|
|
46
|
+
]
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Shared audio encoder configuration operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from libonvif.devices.camera import (
|
|
10
|
+
get_camera_by_ip,
|
|
11
|
+
set_audio_encoder_configuration,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
from .credentials import get_camera_credentials
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def _set_audio_encoder_value(
|
|
20
|
+
ip_address: str,
|
|
21
|
+
profile_token: str,
|
|
22
|
+
value: Any,
|
|
23
|
+
*,
|
|
24
|
+
setting_name: str,
|
|
25
|
+
apply_value: Callable[[Any, Any], None],
|
|
26
|
+
) -> str:
|
|
27
|
+
try:
|
|
28
|
+
credentials = get_camera_credentials()
|
|
29
|
+
camera = get_camera_by_ip(
|
|
30
|
+
ip_address,
|
|
31
|
+
credentials.username,
|
|
32
|
+
credentials.password,
|
|
33
|
+
)
|
|
34
|
+
except Exception as ex:
|
|
35
|
+
logger.error("Failed to query camera at %s: %s", ip_address, ex)
|
|
36
|
+
return f"Failed to query camera at {ip_address}: {ex}"
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
camera.errors = None
|
|
40
|
+
for profile in camera.profiles:
|
|
41
|
+
if profile.token == profile_token:
|
|
42
|
+
apply_value(profile.audio_encoder, value)
|
|
43
|
+
set_audio_encoder_configuration(camera, profile.audio_encoder)
|
|
44
|
+
if camera.errors:
|
|
45
|
+
raise Exception(f"Camera returned errors: {camera.errors}")
|
|
46
|
+
return (
|
|
47
|
+
f"Successfully set {setting_name} to {value} for camera "
|
|
48
|
+
f"at {camera.xaddr}, profile {profile_token}."
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
return f"Profile {profile_token} not found on camera at {camera.xaddr}."
|
|
52
|
+
except Exception as ex:
|
|
53
|
+
logger.error(
|
|
54
|
+
"Failed to set %s for camera at %s: %s",
|
|
55
|
+
setting_name,
|
|
56
|
+
camera.xaddr,
|
|
57
|
+
ex,
|
|
58
|
+
)
|
|
59
|
+
return f"Failed to set {setting_name} for camera at {camera.xaddr}: {ex}"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def set_camera_audio_encoding(
|
|
63
|
+
ip_address: str,
|
|
64
|
+
profile_token: str,
|
|
65
|
+
encoding: str,
|
|
66
|
+
) -> str:
|
|
67
|
+
"""
|
|
68
|
+
Set the audio encoding for one media profile on a camera.
|
|
69
|
+
|
|
70
|
+
The encoding must be one of the codecs offered by the camera for the
|
|
71
|
+
selected profile, such as ``AAC`` or ``G711``.
|
|
72
|
+
"""
|
|
73
|
+
return await _set_audio_encoder_value(
|
|
74
|
+
ip_address,
|
|
75
|
+
profile_token,
|
|
76
|
+
encoding,
|
|
77
|
+
setting_name="audio encoding",
|
|
78
|
+
apply_value=lambda encoder, value: setattr(encoder, "encoding", value),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def set_camera_audio_sample_rate(
|
|
83
|
+
ip_address: str,
|
|
84
|
+
profile_token: str,
|
|
85
|
+
sample_rate: int,
|
|
86
|
+
) -> str:
|
|
87
|
+
"""
|
|
88
|
+
Set the audio sample rate for one media profile on a camera.
|
|
89
|
+
|
|
90
|
+
Some cameras couple sample rate and bitrate. Re-query the camera after
|
|
91
|
+
changing this value to confirm the settings actually retained.
|
|
92
|
+
"""
|
|
93
|
+
return await _set_audio_encoder_value(
|
|
94
|
+
ip_address,
|
|
95
|
+
profile_token,
|
|
96
|
+
sample_rate,
|
|
97
|
+
setting_name="audio sample rate",
|
|
98
|
+
apply_value=lambda encoder, value: setattr(
|
|
99
|
+
encoder,
|
|
100
|
+
"sample_rate",
|
|
101
|
+
value,
|
|
102
|
+
),
|
|
103
|
+
)
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Transport-independent camera retrieval and discovery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Mapping, Sequence
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from libonvif.devices.camera import Camera, discover, get_camera_by_ip
|
|
12
|
+
from libonvif.utils.adapters import find_adapters
|
|
13
|
+
from libonvif.utils.serialization import to_dict
|
|
14
|
+
|
|
15
|
+
from .credentials import get_camera_credentials
|
|
16
|
+
from .streaming import build_web_player_url, build_web_snapshot_url
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _get_camera_credentials(camera: Camera) -> None:
|
|
22
|
+
credentials = get_camera_credentials()
|
|
23
|
+
camera.username = credentials.username
|
|
24
|
+
camera.password = credentials.password
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _on_error(xaddr: str, ex: Exception) -> None:
|
|
28
|
+
logger.debug("Camera discovery error at %s: %s", xaddr, ex)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _camera_filled(camera: Camera) -> None:
|
|
32
|
+
logger.debug(
|
|
33
|
+
"Camera filled: %s : %s",
|
|
34
|
+
camera.hostname,
|
|
35
|
+
camera.device_information.serial_number,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def get_camera(ip_address: str) -> str:
|
|
40
|
+
"""Get the full state of a camera at the specified IP address."""
|
|
41
|
+
credentials = get_camera_credentials()
|
|
42
|
+
camera = get_camera_by_ip(
|
|
43
|
+
ip_address,
|
|
44
|
+
credentials.username,
|
|
45
|
+
credentials.password,
|
|
46
|
+
)
|
|
47
|
+
return camera.to_json()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _camera_summary(
|
|
51
|
+
camera: Camera,
|
|
52
|
+
) -> dict[str, Any]:
|
|
53
|
+
data = to_dict(camera)
|
|
54
|
+
dev = data.get("device_information") or {}
|
|
55
|
+
hostname_obj = data.get("hostname") or {}
|
|
56
|
+
xaddr = data.get("xaddr") or ""
|
|
57
|
+
ip_addr = xaddr.split("://", 1)[1].split("/", 1)[0] if "://" in xaddr else ""
|
|
58
|
+
serial_number = dev.get("serial_number") or ""
|
|
59
|
+
|
|
60
|
+
profiles = []
|
|
61
|
+
for profile in data.get("profiles") or []:
|
|
62
|
+
video_encoder = profile.get("video_encoder") or {}
|
|
63
|
+
rate_control = video_encoder.get("rate_control") or {}
|
|
64
|
+
audio_encoder = profile.get("audio_encoder") or {}
|
|
65
|
+
token = profile.get("token") or ""
|
|
66
|
+
profiles.append(
|
|
67
|
+
{
|
|
68
|
+
"token": token,
|
|
69
|
+
"name": profile.get("name") or "",
|
|
70
|
+
"video_encoder": {
|
|
71
|
+
"encoding": video_encoder.get("encoding") or "",
|
|
72
|
+
"resolution": video_encoder.get("resolution") or "",
|
|
73
|
+
"frame_rate_limit": rate_control.get("frame_rate_limit") or 0,
|
|
74
|
+
"bitrate_limit": rate_control.get("bitrate_limit") or 0,
|
|
75
|
+
"gov_length": video_encoder.get("gov_length") or 0,
|
|
76
|
+
},
|
|
77
|
+
"audio_encoder": {
|
|
78
|
+
"encoding": audio_encoder.get("encoding") or "",
|
|
79
|
+
"sample_rate": audio_encoder.get("sample_rate") or 0,
|
|
80
|
+
},
|
|
81
|
+
"stream_uri": profile.get("stream_uri") or "",
|
|
82
|
+
"snapshot_uri": profile.get("snapshot_uri") or "",
|
|
83
|
+
"web_player_url": build_web_player_url(serial_number, token),
|
|
84
|
+
"web_snapshot_url": build_web_snapshot_url(serial_number, token),
|
|
85
|
+
}
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
ptz = data.get("ptz") or {}
|
|
89
|
+
presets = [
|
|
90
|
+
{"token": preset.get("token") or "", "name": preset.get("name") or ""}
|
|
91
|
+
for preset in ptz.get("presets") or []
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
tours = []
|
|
95
|
+
for tour in ptz.get("tours") or []:
|
|
96
|
+
tour_status = tour.get("status") or {}
|
|
97
|
+
tours.append(
|
|
98
|
+
{
|
|
99
|
+
"token": tour.get("token") or "",
|
|
100
|
+
"name": tour.get("name") or "",
|
|
101
|
+
"status": tour_status.get("state") or "",
|
|
102
|
+
"spot_count": len(tour.get("spots") or []),
|
|
103
|
+
}
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
ptz_status = ptz.get("status") or {}
|
|
107
|
+
capabilities = data.get("capabilities") or {}
|
|
108
|
+
ptz_capabilities = capabilities.get("ptz") or {}
|
|
109
|
+
event_properties = data.get("event_properties") or {}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
"hostname": hostname_obj.get("name") or data.get("name") or "",
|
|
113
|
+
"ip_address": ip_addr,
|
|
114
|
+
"manufacturer": dev.get("manufacturer") or "",
|
|
115
|
+
"model": dev.get("model") or "",
|
|
116
|
+
"firmware_version": dev.get("firmware_version") or "",
|
|
117
|
+
"serial_number": dev.get("serial_number") or "",
|
|
118
|
+
"profiles": profiles,
|
|
119
|
+
"ptz_presets": presets,
|
|
120
|
+
"ptz_tours": tours,
|
|
121
|
+
"ptz_status": {
|
|
122
|
+
"pan_tilt": ptz_status.get("pan_tilt_status") or "",
|
|
123
|
+
"zoom": ptz_status.get("zoom_status") or "",
|
|
124
|
+
},
|
|
125
|
+
"ptz_xaddr": ptz_capabilities.get("xaddr") or "",
|
|
126
|
+
"event_topics": event_properties.get("topic_set") or [],
|
|
127
|
+
"time_offset": int(data.get("time_offset") or 0),
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
async def get_adapters() -> str:
|
|
132
|
+
"""Return a list of available active network adapters.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
A delimited string containing the IP address of each active adapter,
|
|
136
|
+
one per line, separated by "\n--\n".
|
|
137
|
+
"""
|
|
138
|
+
adapter_ips = find_adapters()
|
|
139
|
+
if not adapter_ips:
|
|
140
|
+
return ""
|
|
141
|
+
|
|
142
|
+
return "\n--\n".join(adapter_ips)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
async def get_cameras() -> str:
|
|
146
|
+
"""Discover cameras and return lightweight, delimited JSON summaries."""
|
|
147
|
+
# Get all adapter IPs
|
|
148
|
+
adapter_ips = find_adapters()
|
|
149
|
+
if adapter_ips:
|
|
150
|
+
logger.debug("Host IP addresses: %s", adapter_ips)
|
|
151
|
+
|
|
152
|
+
# Discover cameras on each adapter and merge results
|
|
153
|
+
all_cameras = []
|
|
154
|
+
seen_ips = set()
|
|
155
|
+
|
|
156
|
+
for adapter_ip in adapter_ips:
|
|
157
|
+
logger.debug("Discovering cameras on adapter %s", adapter_ip)
|
|
158
|
+
cameras = discover(
|
|
159
|
+
adapter_ip,
|
|
160
|
+
_get_camera_credentials,
|
|
161
|
+
on_error=_on_error,
|
|
162
|
+
camera_filled=_camera_filled,
|
|
163
|
+
use_threads=True,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
for camera in cameras:
|
|
167
|
+
# Get camera IP for deduplication
|
|
168
|
+
xaddr = getattr(camera, "xaddr", None) or ""
|
|
169
|
+
ip_addr = xaddr.split("://", 1)[1].split("/", 1)[0] if "://" in xaddr else ""
|
|
170
|
+
|
|
171
|
+
# Skip if we've already seen this camera
|
|
172
|
+
if ip_addr in seen_ips:
|
|
173
|
+
logger.debug("Skipping duplicate camera %s (already discovered)", ip_addr)
|
|
174
|
+
continue
|
|
175
|
+
|
|
176
|
+
seen_ips.add(ip_addr)
|
|
177
|
+
all_cameras.append(camera)
|
|
178
|
+
|
|
179
|
+
logger.debug("Discovered %d camera(s) total", len(all_cameras))
|
|
180
|
+
|
|
181
|
+
summaries = []
|
|
182
|
+
for camera in all_cameras:
|
|
183
|
+
try:
|
|
184
|
+
summaries.append(json.dumps(_camera_summary(camera)))
|
|
185
|
+
except Exception as ex:
|
|
186
|
+
logger.error(
|
|
187
|
+
"Failed to serialize camera at %s: %s",
|
|
188
|
+
getattr(camera, "xaddr", "?"),
|
|
189
|
+
ex,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
return "\n--\n".join(summaries)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
async def get_cameras_by_adapter(adapter_ip: str) -> str:
|
|
196
|
+
"""Discover cameras on a specific adapter subnet and return lightweight summaries.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
adapter_ip: The IP address of the adapter to discover cameras on.
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
A delimited string containing a summary dict for each camera found on
|
|
203
|
+
the specified adapter subnet. Each camera's summary is separated by a line
|
|
204
|
+
containing `--`.
|
|
205
|
+
"""
|
|
206
|
+
logger.debug("Discovering cameras on adapter %s", adapter_ip)
|
|
207
|
+
cameras = discover(
|
|
208
|
+
adapter_ip,
|
|
209
|
+
_get_camera_credentials,
|
|
210
|
+
on_error=_on_error,
|
|
211
|
+
camera_filled=_camera_filled,
|
|
212
|
+
use_threads=True,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
summaries = []
|
|
216
|
+
for camera in cameras:
|
|
217
|
+
try:
|
|
218
|
+
summaries.append(json.dumps(_camera_summary(camera)))
|
|
219
|
+
except Exception as ex:
|
|
220
|
+
logger.error(
|
|
221
|
+
"Failed to serialize camera at %s: %s",
|
|
222
|
+
getattr(camera, "xaddr", "?"),
|
|
223
|
+
ex,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
return "\n--\n".join(summaries)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Shared camera credential access for both MCP transports."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class CameraCredentials:
|
|
9
|
+
"""Camera login values; omit the password from diagnostic representations."""
|
|
10
|
+
|
|
11
|
+
username: str
|
|
12
|
+
password: str = field(repr=False)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_camera_credentials() -> CameraCredentials:
|
|
16
|
+
"""Read the current environment, preserving empty defaults and whitespace.
|
|
17
|
+
|
|
18
|
+
Credential acquisition is centralized here so callers do not depend on the
|
|
19
|
+
source. Values are not cached; this preserves the existing lookup behavior.
|
|
20
|
+
"""
|
|
21
|
+
return CameraCredentials(
|
|
22
|
+
username=os.environ.get("CAMERA_USERNAME", ""),
|
|
23
|
+
password=os.environ.get("CAMERA_PASSWORD", ""),
|
|
24
|
+
)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Shared camera device-management operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from libonvif.devices.camera import (
|
|
8
|
+
get_camera_by_ip,
|
|
9
|
+
get_local_date_and_time,
|
|
10
|
+
get_time_offset,
|
|
11
|
+
reboot,
|
|
12
|
+
set_hostname,
|
|
13
|
+
set_system_date_and_time,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
from .credentials import get_camera_credentials
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _query_camera(ip_address: str):
|
|
22
|
+
credentials = get_camera_credentials()
|
|
23
|
+
return get_camera_by_ip(
|
|
24
|
+
ip_address,
|
|
25
|
+
credentials.username,
|
|
26
|
+
credentials.password,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _raise_camera_errors(camera) -> None:
|
|
31
|
+
if camera.errors:
|
|
32
|
+
raise Exception(f"Camera returned errors: {camera.errors}")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def change_camera_hostname(ip_address: str, new_hostname: str) -> str:
|
|
36
|
+
"""Change a camera's hostname by IP address."""
|
|
37
|
+
try:
|
|
38
|
+
camera = _query_camera(ip_address)
|
|
39
|
+
except Exception as ex:
|
|
40
|
+
logger.error("Failed to query camera at %s: %s", ip_address, ex)
|
|
41
|
+
return f"Failed to query camera at {ip_address}: {ex}"
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
camera.hostname.name = new_hostname
|
|
45
|
+
camera.errors = None
|
|
46
|
+
set_hostname(camera)
|
|
47
|
+
_raise_camera_errors(camera)
|
|
48
|
+
return (
|
|
49
|
+
f"Successfully changed hostname of camera at {camera.xaddr} "
|
|
50
|
+
f"to {new_hostname}."
|
|
51
|
+
)
|
|
52
|
+
except Exception as ex:
|
|
53
|
+
logger.error(
|
|
54
|
+
"Failed to change hostname for camera at %s: %s",
|
|
55
|
+
camera.xaddr,
|
|
56
|
+
ex,
|
|
57
|
+
)
|
|
58
|
+
return f"Failed to change hostname for camera at {camera.xaddr}: {ex}"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def sync_camera_time(ip_address: str) -> str:
|
|
62
|
+
"""Synchronize a camera's clock with this machine's local time."""
|
|
63
|
+
try:
|
|
64
|
+
camera = _query_camera(ip_address)
|
|
65
|
+
except Exception as ex:
|
|
66
|
+
logger.error("Failed to query camera at %s: %s", ip_address, ex)
|
|
67
|
+
return f"Failed to query camera at {ip_address}: {ex}"
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
camera.errors = None
|
|
71
|
+
set_system_date_and_time(camera, get_local_date_and_time())
|
|
72
|
+
_raise_camera_errors(camera)
|
|
73
|
+
get_time_offset(camera)
|
|
74
|
+
return (
|
|
75
|
+
f"Successfully synchronized time for camera at {camera.xaddr}. "
|
|
76
|
+
f"time_offset is now {camera.time_offset} seconds."
|
|
77
|
+
)
|
|
78
|
+
except Exception as ex:
|
|
79
|
+
logger.error("Failed to sync time for camera at %s: %s", camera.xaddr, ex)
|
|
80
|
+
return f"Failed to sync time for camera at {camera.xaddr}: {ex}"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def reboot_camera(ip_address: str) -> str:
|
|
84
|
+
"""Request that a camera reboot through ONVIF SystemReboot."""
|
|
85
|
+
try:
|
|
86
|
+
camera = _query_camera(ip_address)
|
|
87
|
+
except Exception as ex:
|
|
88
|
+
logger.error("Failed to query camera at %s: %s", ip_address, ex)
|
|
89
|
+
return f"Failed to query camera at {ip_address}: {ex}"
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
camera.errors = None
|
|
93
|
+
reboot(camera)
|
|
94
|
+
_raise_camera_errors(camera)
|
|
95
|
+
return f"Successfully requested reboot for camera at {camera.xaddr}."
|
|
96
|
+
except Exception as ex:
|
|
97
|
+
logger.error("Failed to reboot camera at %s: %s", camera.xaddr, ex)
|
|
98
|
+
return f"Failed to reboot camera at {camera.xaddr}: {ex}"
|