tescmd 0.1.2__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.
Files changed (81) hide show
  1. tescmd/__init__.py +3 -0
  2. tescmd/__main__.py +5 -0
  3. tescmd/_internal/__init__.py +0 -0
  4. tescmd/_internal/async_utils.py +25 -0
  5. tescmd/_internal/permissions.py +43 -0
  6. tescmd/_internal/vin.py +44 -0
  7. tescmd/api/__init__.py +1 -0
  8. tescmd/api/charging.py +102 -0
  9. tescmd/api/client.py +189 -0
  10. tescmd/api/command.py +540 -0
  11. tescmd/api/energy.py +146 -0
  12. tescmd/api/errors.py +76 -0
  13. tescmd/api/partner.py +40 -0
  14. tescmd/api/sharing.py +65 -0
  15. tescmd/api/signed_command.py +277 -0
  16. tescmd/api/user.py +38 -0
  17. tescmd/api/vehicle.py +150 -0
  18. tescmd/auth/__init__.py +1 -0
  19. tescmd/auth/oauth.py +312 -0
  20. tescmd/auth/server.py +108 -0
  21. tescmd/auth/token_store.py +273 -0
  22. tescmd/ble/__init__.py +0 -0
  23. tescmd/cache/__init__.py +6 -0
  24. tescmd/cache/keys.py +51 -0
  25. tescmd/cache/response_cache.py +213 -0
  26. tescmd/cli/__init__.py +0 -0
  27. tescmd/cli/_client.py +603 -0
  28. tescmd/cli/_options.py +126 -0
  29. tescmd/cli/auth.py +682 -0
  30. tescmd/cli/billing.py +240 -0
  31. tescmd/cli/cache.py +85 -0
  32. tescmd/cli/charge.py +610 -0
  33. tescmd/cli/climate.py +501 -0
  34. tescmd/cli/energy.py +385 -0
  35. tescmd/cli/key.py +611 -0
  36. tescmd/cli/main.py +601 -0
  37. tescmd/cli/media.py +146 -0
  38. tescmd/cli/nav.py +242 -0
  39. tescmd/cli/partner.py +112 -0
  40. tescmd/cli/raw.py +75 -0
  41. tescmd/cli/security.py +495 -0
  42. tescmd/cli/setup.py +786 -0
  43. tescmd/cli/sharing.py +188 -0
  44. tescmd/cli/software.py +81 -0
  45. tescmd/cli/status.py +106 -0
  46. tescmd/cli/trunk.py +240 -0
  47. tescmd/cli/user.py +145 -0
  48. tescmd/cli/vehicle.py +837 -0
  49. tescmd/config/__init__.py +0 -0
  50. tescmd/crypto/__init__.py +19 -0
  51. tescmd/crypto/ecdh.py +46 -0
  52. tescmd/crypto/keys.py +122 -0
  53. tescmd/deploy/__init__.py +0 -0
  54. tescmd/deploy/github_pages.py +268 -0
  55. tescmd/models/__init__.py +85 -0
  56. tescmd/models/auth.py +108 -0
  57. tescmd/models/command.py +18 -0
  58. tescmd/models/config.py +63 -0
  59. tescmd/models/energy.py +56 -0
  60. tescmd/models/sharing.py +26 -0
  61. tescmd/models/user.py +37 -0
  62. tescmd/models/vehicle.py +185 -0
  63. tescmd/output/__init__.py +5 -0
  64. tescmd/output/formatter.py +132 -0
  65. tescmd/output/json_output.py +83 -0
  66. tescmd/output/rich_output.py +809 -0
  67. tescmd/protocol/__init__.py +23 -0
  68. tescmd/protocol/commands.py +175 -0
  69. tescmd/protocol/encoder.py +122 -0
  70. tescmd/protocol/metadata.py +116 -0
  71. tescmd/protocol/payloads.py +621 -0
  72. tescmd/protocol/protobuf/__init__.py +6 -0
  73. tescmd/protocol/protobuf/messages.py +564 -0
  74. tescmd/protocol/session.py +318 -0
  75. tescmd/protocol/signer.py +84 -0
  76. tescmd/py.typed +0 -0
  77. tescmd-0.1.2.dist-info/METADATA +458 -0
  78. tescmd-0.1.2.dist-info/RECORD +81 -0
  79. tescmd-0.1.2.dist-info/WHEEL +4 -0
  80. tescmd-0.1.2.dist-info/entry_points.txt +2 -0
  81. tescmd-0.1.2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel, field_validator
4
+ from pydantic_settings import BaseSettings, SettingsConfigDict
5
+
6
+
7
+ class Profile(BaseModel):
8
+ """A named profile grouping common CLI settings."""
9
+
10
+ region: str = "na"
11
+ vin: str | None = None
12
+ output_format: str | None = None
13
+ client_id: str | None = None
14
+ client_secret: str | None = None
15
+
16
+
17
+ class AppSettings(BaseSettings):
18
+ """Application-wide settings populated from environment variables and .env file."""
19
+
20
+ model_config = SettingsConfigDict(
21
+ env_file=".env",
22
+ env_prefix="TESLA_",
23
+ extra="ignore",
24
+ )
25
+
26
+ client_id: str | None = None
27
+ client_secret: str | None = None
28
+ domain: str | None = None
29
+ vin: str | None = None
30
+
31
+ @field_validator("domain", mode="before")
32
+ @classmethod
33
+ def _lowercase_domain(cls, v: str | None) -> str | None:
34
+ """Tesla Fleet API rejects domains with uppercase characters."""
35
+ if v is not None:
36
+ return v.lower()
37
+ return v
38
+
39
+ region: str = "na"
40
+ token_file: str | None = None
41
+ config_dir: str = "~/.config/tescmd"
42
+ output_format: str | None = None
43
+ profile: str = "default"
44
+ setup_tier: str | None = None
45
+ github_repo: str | None = None
46
+ access_token: str | None = None
47
+ refresh_token: str | None = None
48
+
49
+ # Cache settings (TESLA_CACHE_ENABLED, TESLA_CACHE_TTL, TESLA_CACHE_DIR)
50
+ cache_enabled: bool = True
51
+ cache_ttl: int = 60
52
+ cache_dir: str = "~/.cache/tescmd"
53
+
54
+ # Command protocol: auto | signed | unsigned
55
+ # auto = use signed when keys available, fall back to unsigned
56
+ # signed = require signed (error if no keys)
57
+ # unsigned = force legacy REST path
58
+ command_protocol: str = "auto"
59
+
60
+ # Display units (TESLA_TEMP_UNIT, TESLA_DISTANCE_UNIT, TESLA_PRESSURE_UNIT)
61
+ temp_unit: str = "F"
62
+ distance_unit: str = "mi"
63
+ pressure_unit: str = "psi"
@@ -0,0 +1,56 @@
1
+ """Pydantic models for Tesla energy products (Powerwall, Solar)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from pydantic import BaseModel, ConfigDict
8
+
9
+ _EXTRA_ALLOW = ConfigDict(extra="allow")
10
+
11
+
12
+ class EnergySite(BaseModel):
13
+ model_config = _EXTRA_ALLOW
14
+
15
+ energy_site_id: int
16
+ resource_type: str | None = None
17
+ site_name: str | None = None
18
+ gateway_id: str | None = None
19
+
20
+
21
+ class LiveStatus(BaseModel):
22
+ model_config = _EXTRA_ALLOW
23
+
24
+ solar_power: float | None = None
25
+ battery_power: float | None = None
26
+ grid_power: float | None = None
27
+ load_power: float | None = None
28
+ grid_status: str | None = None
29
+ battery_level: float | None = None
30
+ percentage_charged: float | None = None
31
+
32
+
33
+ class SiteInfo(BaseModel):
34
+ model_config = _EXTRA_ALLOW
35
+
36
+ energy_site_id: int | None = None
37
+ site_name: str | None = None
38
+ resource_type: str | None = None
39
+ backup_reserve_percent: float | None = None
40
+ default_real_mode: str | None = None
41
+ storm_mode_enabled: bool | None = None
42
+ installation_date: str | None = None
43
+
44
+
45
+ class CalendarHistory(BaseModel):
46
+ model_config = _EXTRA_ALLOW
47
+
48
+ serial_number: str | None = None
49
+ time_series: list[dict[str, Any]] = []
50
+
51
+
52
+ class GridImportExportConfig(BaseModel):
53
+ model_config = _EXTRA_ALLOW
54
+
55
+ disallow_charge_from_grid_with_solar_installed: bool | None = None
56
+ customer_preferred_export_rule: str | None = None
@@ -0,0 +1,26 @@
1
+ """Pydantic models for Tesla vehicle sharing (drivers and invites)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pydantic import BaseModel, ConfigDict
6
+
7
+ _EXTRA_ALLOW = ConfigDict(extra="allow")
8
+
9
+
10
+ class ShareDriverInfo(BaseModel):
11
+ model_config = _EXTRA_ALLOW
12
+
13
+ share_user_id: int | None = None
14
+ email: str | None = None
15
+ status: str | None = None
16
+ public_key: str | None = None
17
+
18
+
19
+ class ShareInvite(BaseModel):
20
+ model_config = _EXTRA_ALLOW
21
+
22
+ id: str | None = None
23
+ code: str | None = None
24
+ created_at: str | None = None
25
+ expires_at: str | None = None
26
+ status: str | None = None
tescmd/models/user.py ADDED
@@ -0,0 +1,37 @@
1
+ """Pydantic models for Tesla user account data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pydantic import BaseModel, ConfigDict
6
+
7
+ _EXTRA_ALLOW = ConfigDict(extra="allow")
8
+
9
+
10
+ class UserInfo(BaseModel):
11
+ model_config = _EXTRA_ALLOW
12
+
13
+ email: str | None = None
14
+ full_name: str | None = None
15
+ profile_image_url: str | None = None
16
+
17
+
18
+ class UserRegion(BaseModel):
19
+ model_config = _EXTRA_ALLOW
20
+
21
+ region: str | None = None
22
+ fleet_api_base_url: str | None = None
23
+
24
+
25
+ class VehicleOrder(BaseModel):
26
+ model_config = _EXTRA_ALLOW
27
+
28
+ order_id: str | None = None
29
+ vin: str | None = None
30
+ model: str | None = None
31
+ status: str | None = None
32
+
33
+
34
+ class FeatureConfig(BaseModel):
35
+ model_config = _EXTRA_ALLOW
36
+
37
+ signaling: dict[str, bool] | None = None
@@ -0,0 +1,185 @@
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel, ConfigDict
4
+
5
+ _EXTRA_ALLOW = ConfigDict(extra="allow")
6
+
7
+
8
+ class Vehicle(BaseModel):
9
+ model_config = _EXTRA_ALLOW
10
+
11
+ vin: str
12
+ display_name: str | None = None
13
+ state: str = "unknown"
14
+ vehicle_id: int | None = None
15
+ access_type: str | None = None
16
+
17
+
18
+ class DriveState(BaseModel):
19
+ model_config = _EXTRA_ALLOW
20
+
21
+ latitude: float | None = None
22
+ longitude: float | None = None
23
+ heading: int | None = None
24
+ speed: int | None = None
25
+ power: int | None = None
26
+ shift_state: str | None = None
27
+ timestamp: int | None = None
28
+
29
+
30
+ class ChargeState(BaseModel):
31
+ model_config = _EXTRA_ALLOW
32
+
33
+ battery_level: int | None = None
34
+ battery_range: float | None = None
35
+ charge_limit_soc: int | None = None
36
+ charging_state: str | None = None
37
+ charge_rate: float | None = None
38
+ charger_voltage: int | None = None
39
+ charger_actual_current: int | None = None
40
+ charge_port_door_open: bool | None = None
41
+ minutes_to_full_charge: int | None = None
42
+ scheduled_charging_start_time: int | None = None
43
+ charger_type: str | None = None
44
+ ideal_battery_range: float | None = None
45
+ est_battery_range: float | None = None
46
+ usable_battery_level: int | None = None
47
+ charge_energy_added: float | None = None
48
+ charge_miles_added_rated: float | None = None
49
+ charger_power: int | None = None
50
+ time_to_full_charge: float | None = None
51
+ scheduled_charging_mode: str | None = None
52
+ scheduled_departure_time_minutes: int | None = None
53
+ preconditioning_enabled: bool | None = None
54
+ battery_heater_on: bool | None = None
55
+ charge_port_latch: str | None = None
56
+ conn_charge_cable: str | None = None
57
+
58
+
59
+ class ClimateState(BaseModel):
60
+ model_config = _EXTRA_ALLOW
61
+
62
+ inside_temp: float | None = None
63
+ outside_temp: float | None = None
64
+ driver_temp_setting: float | None = None
65
+ passenger_temp_setting: float | None = None
66
+ is_climate_on: bool | None = None
67
+ fan_status: int | None = None
68
+ defrost_mode: int | None = None
69
+ seat_heater_left: int | None = None
70
+ seat_heater_right: int | None = None
71
+ steering_wheel_heater: bool | None = None
72
+ cabin_overheat_protection: str | None = None
73
+ cabin_overheat_protection_actively_cooling: bool | None = None
74
+ is_auto_conditioning_on: bool | None = None
75
+ is_preconditioning: bool | None = None
76
+ seat_heater_rear_left: int | None = None
77
+ seat_heater_rear_center: int | None = None
78
+ seat_heater_rear_right: int | None = None
79
+ bioweapon_defense_mode: bool | None = None
80
+
81
+
82
+ class SoftwareUpdateInfo(BaseModel):
83
+ model_config = _EXTRA_ALLOW
84
+
85
+ status: str | None = None
86
+ version: str | None = None
87
+ install_perc: int | None = None
88
+ expected_duration_sec: int | None = None
89
+ scheduled_time_ms: int | None = None
90
+ download_perc: int | None = None
91
+
92
+
93
+ class VehicleState(BaseModel):
94
+ model_config = _EXTRA_ALLOW
95
+
96
+ locked: bool | None = None
97
+ odometer: float | None = None
98
+ sentry_mode: bool | None = None
99
+ car_version: str | None = None
100
+ door_driver_front: int | None = None
101
+ door_driver_rear: int | None = None
102
+ door_passenger_front: int | None = None
103
+ door_passenger_rear: int | None = None
104
+ window_driver_front: int | None = None
105
+ window_driver_rear: int | None = None
106
+ window_passenger_front: int | None = None
107
+ window_passenger_rear: int | None = None
108
+ ft: int | None = None
109
+ rt: int | None = None
110
+ homelink_nearby: bool | None = None
111
+ is_user_present: bool | None = None
112
+ center_display_state: int | None = None
113
+ dashcam_state: str | None = None
114
+ remote_start_enabled: bool | None = None
115
+ tpms_pressure_fl: float | None = None
116
+ tpms_pressure_fr: float | None = None
117
+ tpms_pressure_rl: float | None = None
118
+ tpms_pressure_rr: float | None = None
119
+ software_update: SoftwareUpdateInfo | None = None
120
+
121
+
122
+ class VehicleConfig(BaseModel):
123
+ model_config = _EXTRA_ALLOW
124
+
125
+ car_type: str | None = None
126
+ trim_badging: str | None = None
127
+ exterior_color: str | None = None
128
+ wheel_type: str | None = None
129
+ can_accept_navigation_requests: bool | None = None
130
+ can_actuate_trunks: bool | None = None
131
+ eu_vehicle: bool | None = None
132
+ has_seat_cooling: bool | None = None
133
+ motorized_charge_port: bool | None = None
134
+ plg: bool | None = None
135
+ roof_color: str | None = None
136
+
137
+
138
+ class GuiSettings(BaseModel):
139
+ model_config = _EXTRA_ALLOW
140
+
141
+ gui_distance_units: str | None = None
142
+ gui_temperature_units: str | None = None
143
+ gui_charge_rate_units: str | None = None
144
+
145
+
146
+ class VehicleData(BaseModel):
147
+ model_config = _EXTRA_ALLOW
148
+
149
+ vin: str
150
+ display_name: str | None = None
151
+ state: str = "unknown"
152
+ vehicle_id: int | None = None
153
+ charge_state: ChargeState | None = None
154
+ climate_state: ClimateState | None = None
155
+ drive_state: DriveState | None = None
156
+ vehicle_state: VehicleState | None = None
157
+ vehicle_config: VehicleConfig | None = None
158
+ gui_settings: GuiSettings | None = None
159
+
160
+
161
+ class SuperchargerInfo(BaseModel):
162
+ model_config = _EXTRA_ALLOW
163
+
164
+ name: str | None = None
165
+ location: dict[str, float] | None = None
166
+ distance_miles: float | None = None
167
+ total_stalls: int | None = None
168
+ available_stalls: int | None = None
169
+
170
+
171
+ class DestChargerInfo(BaseModel):
172
+ model_config = _EXTRA_ALLOW
173
+
174
+ name: str | None = None
175
+ location: dict[str, float] | None = None
176
+ distance_miles: float | None = None
177
+
178
+
179
+ class NearbyChargingSites(BaseModel):
180
+ model_config = _EXTRA_ALLOW
181
+
182
+ superchargers: list[SuperchargerInfo] = []
183
+ destination_charging: list[DestChargerInfo] = []
184
+ congestion_sync_time_utc_secs: int | None = None
185
+ timestamp: int | None = None
@@ -0,0 +1,5 @@
1
+ """Output formatting for tescmd."""
2
+
3
+ from tescmd.output.formatter import OutputFormatter
4
+
5
+ __all__ = ["OutputFormatter"]
@@ -0,0 +1,132 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from typing import TYPE_CHECKING, Any
5
+
6
+ from rich.console import Console
7
+
8
+ from tescmd.output.json_output import format_json_error, format_json_response
9
+ from tescmd.output.rich_output import DisplayUnits, RichOutput
10
+
11
+ if TYPE_CHECKING:
12
+ from io import TextIOBase
13
+
14
+
15
+ class OutputFormatter:
16
+ """Unified output formatter that auto-detects JSON vs Rich output.
17
+
18
+ Selection logic:
19
+
20
+ * If *force_format* is provided, use it unconditionally.
21
+ * Otherwise, if *stream* (default ``sys.stdout``) is a TTY, use ``"rich"``.
22
+ * If the stream is **not** a TTY (piped / redirected), use ``"json"``.
23
+
24
+ When the format is ``"quiet"``, a :class:`rich.console.Console` writing to
25
+ *stderr* is used so that normal stdout stays empty.
26
+
27
+ Error stream routing:
28
+
29
+ * **JSON / piped** — errors go to **stderr** so stdout stays clean for
30
+ machine-parseable data (callers can safely ``| jq``).
31
+ * **Rich / TTY** — errors stay on **stdout** because the user is looking
32
+ at the terminal directly; splitting streams would be worse UX.
33
+ * Interactive prompts (wake confirmation, enrollment approval) always use
34
+ stdout via Rich since they are inherently TTY-only.
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ *,
40
+ stream: TextIOBase | Any | None = None,
41
+ force_format: str | None = None,
42
+ units: DisplayUnits | None = None,
43
+ ) -> None:
44
+ self._stream = stream or sys.stdout
45
+ if force_format is not None:
46
+ self._format = force_format
47
+ elif hasattr(self._stream, "isatty") and self._stream.isatty():
48
+ self._format = "rich"
49
+ else:
50
+ self._format = "json"
51
+
52
+ # Build the Rich console — quiet mode writes to stderr.
53
+ if self._format == "quiet":
54
+ self._console = Console(stderr=True)
55
+ else:
56
+ self._console = Console()
57
+
58
+ # Separate stderr console for error output so stdout stays clean
59
+ # for machine-parseable data (JSON, piped workflows).
60
+ self._error_console = Console(stderr=True)
61
+
62
+ self._rich = RichOutput(self._console, units=units)
63
+ self._cache_meta: dict[str, Any] | None = None
64
+
65
+ # ------------------------------------------------------------------
66
+ # Public helpers
67
+ # ------------------------------------------------------------------
68
+
69
+ @property
70
+ def format(self) -> str:
71
+ """Return the active output format (``"rich"``, ``"json"``, or ``"quiet"``)."""
72
+ return self._format
73
+
74
+ @property
75
+ def console(self) -> Console:
76
+ """Return the underlying :class:`Console` instance."""
77
+ return self._console
78
+
79
+ @property
80
+ def rich(self) -> RichOutput:
81
+ """Return the underlying :class:`RichOutput` instance."""
82
+ return self._rich
83
+
84
+ def set_cache_meta(
85
+ self,
86
+ *,
87
+ hit: bool,
88
+ age_seconds: int,
89
+ ttl_seconds: int,
90
+ ) -> None:
91
+ """Store cache metadata to be included in the next JSON output."""
92
+ self._cache_meta = {
93
+ "hit": hit,
94
+ "age_seconds": age_seconds,
95
+ "ttl_seconds": ttl_seconds,
96
+ }
97
+
98
+ def output(self, data: Any, *, command: str) -> None:
99
+ """Emit *data* using the current format.
100
+
101
+ * **json** — prints :func:`format_json_response` to stdout.
102
+ * **rich** / **quiet** — delegates to :attr:`rich` methods when the
103
+ data type is recognised, otherwise falls back to
104
+ :meth:`RichOutput.info` with a ``str()`` representation.
105
+ """
106
+ if self._format == "json":
107
+ print(format_json_response(data=data, command=command, cache_meta=self._cache_meta))
108
+ self._cache_meta = None
109
+ else:
110
+ # Rich / quiet fallback — callers normally use self.rich directly
111
+ # for typed output; this is a catch-all.
112
+ self._rich.info(str(data))
113
+
114
+ @property
115
+ def error_console(self) -> Console:
116
+ """Return the stderr :class:`Console` for error output."""
117
+ return self._error_console
118
+
119
+ def output_error(self, *, code: str, message: str, command: str) -> None:
120
+ """Emit an error using the current format.
121
+
122
+ * **json** — prints :func:`format_json_error` to stderr.
123
+ * **rich** / **quiet** — prints via :meth:`RichOutput.error` (stdout,
124
+ since TTY users see the terminal directly).
125
+ """
126
+ if self._format == "json":
127
+ print(
128
+ format_json_error(code=code, message=message, command=command),
129
+ file=sys.stderr,
130
+ )
131
+ else:
132
+ self._rich.error(message)
@@ -0,0 +1,83 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from datetime import UTC, datetime
5
+ from typing import Any
6
+
7
+ from pydantic import BaseModel
8
+
9
+
10
+ def _serialize(obj: Any) -> Any:
11
+ """Convert *obj* to a JSON-friendly structure.
12
+
13
+ * :class:`pydantic.BaseModel` instances are dumped via
14
+ :meth:`~pydantic.BaseModel.model_dump` with *exclude_none=True*.
15
+ * Lists are recursed element-wise.
16
+ * Dicts are recursed value-wise (nested Pydantic models are serialized).
17
+ * Everything else is returned as-is (``json.dumps`` handles the rest via
18
+ *default=str*).
19
+ """
20
+ if isinstance(obj, BaseModel):
21
+ return obj.model_dump(exclude_none=True)
22
+ if isinstance(obj, dict):
23
+ return {k: _serialize(v) for k, v in obj.items()}
24
+ if isinstance(obj, list):
25
+ return [_serialize(item) for item in obj]
26
+ return obj
27
+
28
+
29
+ def format_json_response(
30
+ *,
31
+ data: Any,
32
+ command: str,
33
+ cache_meta: dict[str, Any] | None = None,
34
+ ) -> str:
35
+ """Return a JSON envelope for a successful response.
36
+
37
+ The envelope has the shape::
38
+
39
+ {
40
+ "ok": true,
41
+ "command": "<command>",
42
+ "data": <serialised payload>,
43
+ "timestamp": "<ISO-8601 UTC>",
44
+ "_cache": {"hit": true, "age_seconds": N, "ttl_seconds": M} // optional
45
+ }
46
+ """
47
+ envelope: dict[str, Any] = {
48
+ "ok": True,
49
+ "command": command,
50
+ "data": _serialize(data),
51
+ "timestamp": datetime.now(UTC).isoformat(),
52
+ }
53
+ if cache_meta is not None:
54
+ envelope["_cache"] = cache_meta
55
+ return json.dumps(envelope, indent=2, default=str)
56
+
57
+
58
+ def format_json_error(
59
+ *,
60
+ code: str,
61
+ message: str,
62
+ command: str,
63
+ **extra: Any,
64
+ ) -> str:
65
+ """Return a JSON envelope for an error response.
66
+
67
+ The envelope has the shape::
68
+
69
+ {
70
+ "ok": false,
71
+ "command": "<command>",
72
+ "error": {"code": "...", "message": "...", ...extra},
73
+ "timestamp": "<ISO-8601 UTC>"
74
+ }
75
+ """
76
+ error_body: dict[str, Any] = {"code": code, "message": message, **extra}
77
+ envelope: dict[str, Any] = {
78
+ "ok": False,
79
+ "command": command,
80
+ "error": error_body,
81
+ "timestamp": datetime.now(UTC).isoformat(),
82
+ }
83
+ return json.dumps(envelope, indent=2, default=str)