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.
@@ -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}"