vi-api-client 6.1.1__py3-none-any.whl

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,74 @@
1
+ """Viessmann API Client."""
2
+
3
+ import logging
4
+
5
+ from ._types import FeatureValue, JsonValue, ValidationDetail
6
+ from .auth import AbstractAuth, OAuth
7
+ from .client import ViClient
8
+ from .const import DEFAULT_SCOPES, ENDPOINT_AUTHORIZE, ENDPOINT_TOKEN
9
+ from .exceptions import (
10
+ ViAuthError,
11
+ ViConnectionError,
12
+ ViError,
13
+ ViNotFoundError,
14
+ ViRateLimitError,
15
+ ViResponseError,
16
+ ViServerInternalError,
17
+ ViValidationError,
18
+ )
19
+ from .fixture_client import FixtureViClient
20
+ from .models import (
21
+ CommandResponse,
22
+ Device,
23
+ EventHistoryPage,
24
+ Feature,
25
+ FeatureControl,
26
+ Gateway,
27
+ GatewayDeviceRefreshResult,
28
+ Installation,
29
+ InstallationEvent,
30
+ ScheduleConstraints,
31
+ )
32
+ from .privacy import redact_device, redact_feature, redact_sensitive
33
+ from .utils import format_feature
34
+ from .validation import validate_json_value
35
+
36
+ # Library best practice: consume the package logger without requiring
37
+ # consumer-side logging configuration.
38
+ logging.getLogger(__name__).addHandler(logging.NullHandler())
39
+
40
+ __all__ = [
41
+ "DEFAULT_SCOPES",
42
+ "ENDPOINT_AUTHORIZE",
43
+ "ENDPOINT_TOKEN",
44
+ "AbstractAuth",
45
+ "CommandResponse",
46
+ "Device",
47
+ "EventHistoryPage",
48
+ "Feature",
49
+ "FeatureControl",
50
+ "FeatureValue",
51
+ "FixtureViClient",
52
+ "Gateway",
53
+ "GatewayDeviceRefreshResult",
54
+ "Installation",
55
+ "InstallationEvent",
56
+ "JsonValue",
57
+ "OAuth",
58
+ "ScheduleConstraints",
59
+ "ValidationDetail",
60
+ "ViAuthError",
61
+ "ViClient",
62
+ "ViConnectionError",
63
+ "ViError",
64
+ "ViNotFoundError",
65
+ "ViRateLimitError",
66
+ "ViResponseError",
67
+ "ViServerInternalError",
68
+ "ViValidationError",
69
+ "format_feature",
70
+ "redact_device",
71
+ "redact_feature",
72
+ "redact_sensitive",
73
+ "validate_json_value",
74
+ ]
@@ -0,0 +1,321 @@
1
+ """Private API adapters for live and fixture-backed client workflows."""
2
+
3
+ import logging
4
+ import math
5
+ from collections.abc import Mapping
6
+ from datetime import UTC, datetime
7
+ from email.utils import parsedate_to_datetime
8
+ from typing import Any, Protocol, cast
9
+ from urllib.parse import urljoin
10
+
11
+ import aiohttp
12
+
13
+ from ._types import JsonValue, ValidationDetail
14
+ from .auth import AbstractAuth
15
+ from .const import (
16
+ API_BASE_URL,
17
+ ENDPOINT_EVENT_HISTORY,
18
+ ENDPOINT_FEATURES,
19
+ ENDPOINT_GATEWAYS,
20
+ ENDPOINT_INSTALLATIONS,
21
+ )
22
+ from .exceptions import (
23
+ ViAuthError,
24
+ ViConnectionError,
25
+ ViError,
26
+ ViNotFoundError,
27
+ ViRateLimitError,
28
+ ViResponseError,
29
+ ViServerInternalError,
30
+ ViValidationError,
31
+ )
32
+ from .models import Device, FeatureControl
33
+ from .privacy import redact_sensitive
34
+ from .validation import validate_json_value
35
+
36
+ _LOGGER = logging.getLogger(__name__)
37
+
38
+ # Failures while reading a response body after its headers arrived: the
39
+ # request was not completed, unlike a complete body that is not valid JSON.
40
+ _BODY_READ_ERRORS = (
41
+ TimeoutError,
42
+ aiohttp.ClientConnectionError,
43
+ aiohttp.ClientPayloadError,
44
+ )
45
+
46
+
47
+ class DiscoveryAdapter(Protocol):
48
+ """Return raw API responses; `ViClient` turns them into models.
49
+
50
+ Kept separate from `CommandAdapter` so fixture adapters and test doubles
51
+ implement only the role they exercise; `LiveAdapter` fulfills both.
52
+ """
53
+
54
+ async def get_installations(self) -> object: ...
55
+
56
+ async def get_gateways(self) -> object: ...
57
+
58
+ async def get_devices(
59
+ self, installation_id: str, gateway_serial: str
60
+ ) -> object: ...
61
+
62
+ async def get_features(
63
+ self, device: Device, payload: dict[str, bool]
64
+ ) -> object: ...
65
+
66
+ async def get_gateway_features(
67
+ self, installation_id: str, gateway_serial: str, payload: dict[str, bool]
68
+ ) -> object: ...
69
+
70
+ async def get_event_history(
71
+ self, installation_id: str, params: dict[str, int | str]
72
+ ) -> object: ...
73
+
74
+
75
+ class CommandAdapter(Protocol):
76
+ """Execute feature commands without constructing domain objects.
77
+
78
+ Kept separate from `DiscoveryAdapter` so command-focused test doubles do
79
+ not need to stub discovery methods.
80
+ """
81
+
82
+ async def execute_command(
83
+ self, control: FeatureControl, parameters: dict[str, JsonValue]
84
+ ) -> object: ...
85
+
86
+
87
+ class LiveAdapter:
88
+ """Return raw live API responses through an authenticated request provider."""
89
+
90
+ def __init__(self, auth: AbstractAuth) -> None:
91
+ """Initialize the adapter with the request provider."""
92
+ self._auth = auth
93
+
94
+ async def get_installations(self) -> object:
95
+ """Return the raw installations API response."""
96
+ return await self._request("GET", ENDPOINT_INSTALLATIONS)
97
+
98
+ async def get_gateways(self) -> object:
99
+ """Return the raw gateways API response."""
100
+ return await self._request("GET", ENDPOINT_GATEWAYS)
101
+
102
+ async def get_devices(self, installation_id: str, gateway_serial: str) -> object:
103
+ """Return the raw devices API response."""
104
+ return await self._request(
105
+ "GET",
106
+ f"{ENDPOINT_INSTALLATIONS}/{installation_id}/gateways/"
107
+ f"{gateway_serial}/devices",
108
+ )
109
+
110
+ async def get_features(self, device: Device, payload: dict[str, bool]) -> object:
111
+ """Return the raw feature API response for one device."""
112
+ return await self._request(
113
+ "POST",
114
+ f"{ENDPOINT_FEATURES}/{device.installation_id}/gateways/"
115
+ f"{device.gateway_serial}/devices/{device.id}/features/filter",
116
+ json=payload,
117
+ )
118
+
119
+ async def get_gateway_features(
120
+ self, installation_id: str, gateway_serial: str, payload: dict[str, bool]
121
+ ) -> object:
122
+ """Return the raw gateway-scoped feature API response."""
123
+ return await self._request(
124
+ "POST",
125
+ f"{ENDPOINT_FEATURES}/{installation_id}/gateways/"
126
+ f"{gateway_serial}/features/filter",
127
+ json=payload,
128
+ )
129
+
130
+ async def get_event_history(
131
+ self, installation_id: str, params: dict[str, int | str]
132
+ ) -> object:
133
+ """Return one raw event history API response."""
134
+ return await self._request(
135
+ "GET",
136
+ f"{ENDPOINT_EVENT_HISTORY}/{installation_id}/events",
137
+ params=params,
138
+ )
139
+
140
+ async def execute_command(
141
+ self, control: FeatureControl, parameters: dict[str, JsonValue]
142
+ ) -> object:
143
+ """Return the raw command API response."""
144
+ return await self._request("POST", control.uri, json=parameters)
145
+
146
+ async def _request(
147
+ self,
148
+ method: str,
149
+ url: str,
150
+ *,
151
+ params: Mapping[str, int | str] | None = None,
152
+ json: Mapping[str, object] | None = None,
153
+ ) -> object:
154
+ """Return the decoded JSON body of a successful response.
155
+
156
+ The body is untrusted JSON of any shape; callers validate it.
157
+
158
+ Raises:
159
+ ViConnectionError: If the response body cannot be read completely.
160
+ ViResponseError: If the URL is outside the Vi API or a successful
161
+ response is not valid JSON.
162
+ """
163
+ full_url = _api_url(url)
164
+ _LOGGER.debug("Request: %s %s", method, redact_sensitive(full_url))
165
+ async with await self._auth.request(
166
+ method, full_url, params=params, json=json
167
+ ) as response:
168
+ await _raise_for_status(response)
169
+ try:
170
+ return await response.json()
171
+ except _BODY_READ_ERRORS as error:
172
+ raise ViConnectionError(f"Network error: {error}") from error
173
+ except (aiohttp.ClientError, ValueError) as error:
174
+ raise ViResponseError(
175
+ "Successful API response was not valid JSON"
176
+ ) from error
177
+
178
+
179
+ def _api_url(url: str) -> str:
180
+ """Return the absolute Vi API URL for an endpoint path or command URI.
181
+
182
+ Command URIs come from API responses, and every request carries the
183
+ bearer token, so URLs outside the Vi API are refused rather than sent.
184
+
185
+ Raises:
186
+ ViResponseError: If the URL does not resolve inside the Vi API.
187
+ """
188
+ full_url = urljoin(API_BASE_URL, url)
189
+ if not full_url.startswith(f"{API_BASE_URL}/"):
190
+ raise ViResponseError(
191
+ f"Refusing request outside the Vi API: {redact_sensitive(full_url)}"
192
+ )
193
+ return full_url
194
+
195
+
196
+ async def _raise_for_status(response: aiohttp.ClientResponse) -> None:
197
+ """Raise the matching library error for an unsuccessful API response.
198
+
199
+ The HTTP status decides the exception class. The JSON error body only
200
+ enriches it, so a missing or non-JSON body (such as a proxy error page)
201
+ falls back to HTTP-level defaults instead of masking the status.
202
+
203
+ Raises:
204
+ ViAuthError: For 401 and 403.
205
+ ViNotFoundError: For 404.
206
+ ViRateLimitError: For 429, with parsed Retry-After guidance.
207
+ ViValidationError: For 400 and 422, with validated details.
208
+ ViServerInternalError: For 5xx.
209
+ ViError: For any other unsuccessful status.
210
+ """
211
+ status = response.status
212
+ if status < 400:
213
+ return
214
+
215
+ error_message = f"HTTP {status}"
216
+ vi_error_id: str | None = None
217
+ error_type: str | None = None
218
+ validation_details: list[ValidationDetail] = []
219
+ try:
220
+ data = await response.json()
221
+ except TimeoutError, aiohttp.ClientError, ValueError:
222
+ data = None
223
+ if isinstance(data, dict):
224
+ # The untyped aiohttp JSON boundary yields an unknown container shape;
225
+ # each known error field is re-narrowed and validated below.
226
+ error_body = cast("dict[str, Any]", data)
227
+ vi_error_id = _text_or_none(error_body.get("viErrorId"))
228
+ error_type = _text_or_none(error_body.get("errorType"))
229
+ message = _text_or_none(error_body.get("message"))
230
+ if message is not None:
231
+ error_message = message
232
+ validation_details = _parse_validation_details(
233
+ error_body.get("validationErrors")
234
+ )
235
+
236
+ # The raised library exception carries all error details, so the log
237
+ # stays at debug level to avoid double-reporting normal control flow.
238
+ _LOGGER.debug(
239
+ "API Error %s (%s): %s (ID: %s)",
240
+ status,
241
+ error_type,
242
+ error_message,
243
+ vi_error_id,
244
+ )
245
+ if status in (401, 403):
246
+ description = "Unauthorized" if status == 401 else "Forbidden"
247
+ raise ViAuthError(f"{description}: {error_message}", vi_error_id, error_type)
248
+ if status == 404:
249
+ raise ViNotFoundError(f"Not Found: {error_message}", vi_error_id, error_type)
250
+ if status == 429:
251
+ raise ViRateLimitError(
252
+ "Rate Limit Exceeded",
253
+ vi_error_id,
254
+ error_type,
255
+ retry_after=_parse_retry_after(response.headers.get("Retry-After")),
256
+ )
257
+ # Vi reports unreachable devices as 400 DEVICE_COMMUNICATION_ERROR;
258
+ # ViClient.refresh_gateway_devices relies on this mapping for its
259
+ # per-device fallback.
260
+ if status in (400, 422):
261
+ raise ViValidationError(
262
+ error_message, vi_error_id, validation_details, error_type
263
+ )
264
+ if status >= 500:
265
+ raise ViServerInternalError(
266
+ f"Server Error {status}: {error_message}", vi_error_id, error_type
267
+ )
268
+ raise ViError(f"Unknown Error {status}: {error_message}", vi_error_id, error_type)
269
+
270
+
271
+ def _text_or_none(value: object) -> str | None:
272
+ """Return the value if it is text, otherwise None."""
273
+ return value if isinstance(value, str) else None
274
+
275
+
276
+ def _parse_validation_details(value: object) -> list[ValidationDetail]:
277
+ """Return validated validation details, dropping unusable collections.
278
+
279
+ Every exposed detail is a string-keyed JSON object and unknown detail
280
+ fields remain allowed. A collection that violates the shape — or that
281
+ JSON cannot represent — is dropped entirely rather than masking the
282
+ HTTP error being reported.
283
+ """
284
+ try:
285
+ validated = validate_json_value(value, path="API validationErrors")
286
+ except ViResponseError:
287
+ return []
288
+ if not isinstance(validated, list) or not all(
289
+ isinstance(entry, dict) for entry in validated
290
+ ):
291
+ return []
292
+ # Every entry was runtime-checked as a validated JSON object above.
293
+ return cast("list[ValidationDetail]", validated)
294
+
295
+
296
+ def _parse_retry_after(value: str | None) -> float | None:
297
+ """Return a non-negative retry duration from an HTTP Retry-After value.
298
+
299
+ The header carries either delay seconds or an HTTP-date (RFC 9110). A
300
+ date without a time zone is not a valid HTTP-date and cannot be compared
301
+ with the current UTC time, so it yields None like any unusable value. A
302
+ date in the past yields 0.
303
+ """
304
+ if value is None:
305
+ return None
306
+
307
+ try:
308
+ numeric_delay = float(value)
309
+ except ValueError:
310
+ pass
311
+ else:
312
+ is_valid_delay = math.isfinite(numeric_delay) and numeric_delay >= 0
313
+ return numeric_delay if is_valid_delay else None
314
+
315
+ try:
316
+ retry_at = parsedate_to_datetime(value)
317
+ except IndexError, TypeError, ValueError:
318
+ return None
319
+ if retry_at.tzinfo is None:
320
+ return None
321
+ return max(0.0, (retry_at - datetime.now(UTC)).total_seconds())
@@ -0,0 +1,250 @@
1
+ """Compare two raw API feature documents for the diff-features CLI command."""
2
+
3
+ import json
4
+ from collections.abc import Mapping, Sequence
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, cast
7
+
8
+ _ARROW = "→"
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class FeatureDiff:
13
+ """The differences between a left and a right set of raw API features.
14
+
15
+ ``structure`` and ``state`` map each changed feature name to its change
16
+ lines, sorted by feature name.
17
+ """
18
+
19
+ left_count: int
20
+ right_count: int
21
+ added: list[str] = field(default_factory=list[str])
22
+ removed: list[str] = field(default_factory=list[str])
23
+ structure: dict[str, list[str]] = field(default_factory=dict[str, list[str]])
24
+ state: dict[str, list[str]] = field(default_factory=dict[str, list[str]])
25
+
26
+ @property
27
+ def has_differences(self) -> bool:
28
+ """Return whether any section lists a difference."""
29
+ return bool(self.added or self.removed or self.structure or self.state)
30
+
31
+
32
+ def feature_entries(document: object) -> dict[str, Mapping[str, Any]]:
33
+ """Return a feature document's raw API features keyed by feature name.
34
+
35
+ Args:
36
+ document: A fixture file or device export with a ``data`` list.
37
+
38
+ Raises:
39
+ ValueError: If the document has no ``data`` list of features with
40
+ unique, non-empty names.
41
+ """
42
+ data = _as_mapping(document).get("data")
43
+ if not isinstance(data, list):
44
+ raise ValueError("A feature document must be an object with a 'data' list")
45
+ entries: dict[str, Mapping[str, Any]] = {}
46
+ for item in cast("list[object]", data):
47
+ entry = _as_mapping(item)
48
+ name = entry.get("feature")
49
+ if not isinstance(name, str) or not name:
50
+ raise ValueError("Every feature needs a non-empty 'feature' name")
51
+ if name in entries:
52
+ raise ValueError(f"Duplicate feature in document: {name}")
53
+ entries[name] = entry
54
+ return entries
55
+
56
+
57
+ def diff_features(
58
+ left: Mapping[str, Mapping[str, Any]], right: Mapping[str, Mapping[str, Any]]
59
+ ) -> FeatureDiff:
60
+ """Compare raw API features by name without comparing their values.
61
+
62
+ Properties and commands are compared only when the feature is enabled on
63
+ both sides: the API returns a disabled feature without them, which is a
64
+ state difference rather than a structure difference.
65
+ """
66
+ diff = FeatureDiff(
67
+ left_count=len(left),
68
+ right_count=len(right),
69
+ added=sorted(set(right) - set(left)),
70
+ removed=sorted(set(left) - set(right)),
71
+ )
72
+ for name in sorted(set(left) & set(right)):
73
+ structure = _structure_changes(left[name], right[name])
74
+ if structure:
75
+ diff.structure[name] = structure
76
+ state = _state_changes(left[name], right[name])
77
+ if state:
78
+ diff.state[name] = state
79
+ return diff
80
+
81
+
82
+ def format_feature_diff(
83
+ diff: FeatureDiff, left_label: str, right_label: str
84
+ ) -> list[str]:
85
+ """Return the text report of a feature comparison."""
86
+ lines = [
87
+ f"Comparing {left_label} ({diff.left_count} features) "
88
+ f"with {right_label} ({diff.right_count} features)"
89
+ ]
90
+ if not diff.has_differences:
91
+ lines.append("No differences.")
92
+ return lines
93
+ if diff.added:
94
+ lines.extend(["", f"Added ({len(diff.added)}):"])
95
+ lines.extend(f" + {name}" for name in diff.added)
96
+ if diff.removed:
97
+ lines.extend(["", f"Removed ({len(diff.removed)}):"])
98
+ lines.extend(f" - {name}" for name in diff.removed)
99
+ for title, changes in (
100
+ ("Structure changed", diff.structure),
101
+ ("State changed", diff.state),
102
+ ):
103
+ if changes:
104
+ lines.extend(["", f"{title} ({len(changes)}):"])
105
+ for name, change_lines in changes.items():
106
+ lines.append(f" {name}")
107
+ lines.extend(f" {line}" for line in change_lines)
108
+ return lines
109
+
110
+
111
+ def _structure_changes(left: Mapping[str, Any], right: Mapping[str, Any]) -> list[str]:
112
+ """Return the property, command, and deprecation changes of one feature."""
113
+ changes: list[str] = []
114
+ if left.get("isEnabled") is True and right.get("isEnabled") is True:
115
+ changes.extend(
116
+ _property_changes(
117
+ _mapping(left, "properties"), _mapping(right, "properties")
118
+ )
119
+ )
120
+ changes.extend(
121
+ _command_changes(_mapping(left, "commands"), _mapping(right, "commands"))
122
+ )
123
+ if left.get("deprecated") != right.get("deprecated"):
124
+ changes.append(
125
+ f"deprecated: {_render(left.get('deprecated'))} {_ARROW} "
126
+ f"{_render(right.get('deprecated'))}"
127
+ )
128
+ return changes
129
+
130
+
131
+ def _command_changes(left: Mapping[str, Any], right: Mapping[str, Any]) -> list[str]:
132
+ """Return added and removed commands and their parameter changes."""
133
+ changes = [f"+ command {name}" for name in sorted(set(right) - set(left))]
134
+ changes.extend(f"- command {name}" for name in sorted(set(left) - set(right)))
135
+ for name in sorted(set(left) & set(right)):
136
+ changes.extend(
137
+ _parameter_changes(
138
+ name,
139
+ _mapping(_as_mapping(left[name]), "params"),
140
+ _mapping(_as_mapping(right[name]), "params"),
141
+ )
142
+ )
143
+ return changes
144
+
145
+
146
+ def _property_changes(left: Mapping[str, Any], right: Mapping[str, Any]) -> list[str]:
147
+ """Return added and removed properties and their type and unit changes."""
148
+ changes = [f"+ property {name}" for name in sorted(set(right) - set(left))]
149
+ changes.extend(f"- property {name}" for name in sorted(set(left) - set(right)))
150
+ for name in sorted(set(left) & set(right)):
151
+ left_property = _as_mapping(left[name])
152
+ right_property = _as_mapping(right[name])
153
+ changes.extend(
154
+ _field_changes(
155
+ f"property {name}", left_property, right_property, ("type", "unit")
156
+ )
157
+ )
158
+ return changes
159
+
160
+
161
+ def _parameter_changes(
162
+ command: str, left: Mapping[str, Any], right: Mapping[str, Any]
163
+ ) -> list[str]:
164
+ """Return added and removed parameters and their contract changes."""
165
+ changes = [f"+ {command}.{name}" for name in sorted(set(right) - set(left))]
166
+ changes.extend(f"- {command}.{name}" for name in sorted(set(left) - set(right)))
167
+ for name in sorted(set(left) & set(right)):
168
+ label = f"{command}.{name}"
169
+ left_parameter = _as_mapping(left[name])
170
+ right_parameter = _as_mapping(right[name])
171
+ changes.extend(
172
+ _field_changes(label, left_parameter, right_parameter, ("type", "required"))
173
+ )
174
+ left_constraints = _mapping(left_parameter, "constraints")
175
+ right_constraints = _mapping(right_parameter, "constraints")
176
+ changes.extend(
177
+ _field_changes(
178
+ label,
179
+ left_constraints,
180
+ right_constraints,
181
+ sorted(set(left_constraints) | set(right_constraints)),
182
+ )
183
+ )
184
+ return changes
185
+
186
+
187
+ def _state_changes(left: Mapping[str, Any], right: Mapping[str, Any]) -> list[str]:
188
+ """Return the enabled, ready, and command executability changes."""
189
+ changes = _field_changes("", left, right, ("isEnabled", "isReady"))
190
+ left_commands = _mapping(left, "commands")
191
+ right_commands = _mapping(right, "commands")
192
+ for name in sorted(set(left_commands) & set(right_commands)):
193
+ changes.extend(
194
+ _field_changes(
195
+ f"{name}.",
196
+ _as_mapping(left_commands[name]),
197
+ _as_mapping(right_commands[name]),
198
+ ("isExecutable",),
199
+ )
200
+ )
201
+ return changes
202
+
203
+
204
+ def _field_changes(
205
+ label: str,
206
+ left: Mapping[str, Any],
207
+ right: Mapping[str, Any],
208
+ fields: Sequence[str],
209
+ ) -> list[str]:
210
+ """Return one ``label field: old → new`` line per differing field.
211
+
212
+ A label ending in ``.`` is joined to the field name without a space.
213
+ """
214
+ changes: list[str] = []
215
+ for field_name in fields:
216
+ left_value = left.get(field_name)
217
+ right_value = right.get(field_name)
218
+ if left_value == right_value:
219
+ continue
220
+ prefix = (
221
+ f"{label}{field_name}"
222
+ if label.endswith(".") or not label
223
+ else (f"{label} {field_name}")
224
+ )
225
+ if field_name not in left:
226
+ changes.append(f"{prefix}: + {_render(right_value)}")
227
+ elif field_name not in right:
228
+ changes.append(f"{prefix}: - {_render(left_value)}")
229
+ else:
230
+ changes.append(
231
+ f"{prefix}: {_render(left_value)} {_ARROW} {_render(right_value)}"
232
+ )
233
+ return changes
234
+
235
+
236
+ def _mapping(entry: Mapping[str, Any], key: str) -> Mapping[str, Any]:
237
+ """Return a nested object of a raw entry, or an empty one if it is missing."""
238
+ return _as_mapping(entry.get(key))
239
+
240
+
241
+ def _as_mapping(value: object) -> Mapping[str, Any]:
242
+ """Return a raw JSON object, or an empty one for any other value."""
243
+ return cast("Mapping[str, Any]", value) if isinstance(value, Mapping) else {}
244
+
245
+
246
+ def _render(value: object) -> str:
247
+ """Return a compact JSON rendering of a raw value, ``none`` when absent."""
248
+ if value is None:
249
+ return "none"
250
+ return json.dumps(value, ensure_ascii=False, sort_keys=True)
@@ -0,0 +1,12 @@
1
+ """Public dynamic value contracts shared across client API boundaries."""
2
+
3
+ type JsonValue = (
4
+ bool | int | float | str | list[JsonValue] | dict[str, JsonValue] | None
5
+ )
6
+ """A recursively JSON-compatible value retaining standard Python shapes."""
7
+
8
+ type FeatureValue = JsonValue
9
+ """The dynamic value reported by a device feature."""
10
+
11
+ type ValidationDetail = dict[str, JsonValue]
12
+ """One dictionary-shaped API validation detail."""