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
tescmd/cli/media.py ADDED
@@ -0,0 +1,146 @@
1
+ """CLI commands for media playback control."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ import click
8
+
9
+ from tescmd._internal.async_utils import run_async
10
+ from tescmd.cli._client import execute_command
11
+ from tescmd.cli._options import global_options
12
+
13
+ if TYPE_CHECKING:
14
+ from tescmd.cli.main import AppContext
15
+
16
+ media_group = click.Group("media", help="Media playback commands")
17
+
18
+
19
+ @media_group.command("play-pause")
20
+ @click.argument("vin_positional", required=False, default=None, metavar="VIN")
21
+ @global_options
22
+ def play_pause_cmd(app_ctx: AppContext, vin_positional: str | None) -> None:
23
+ """Toggle media playback."""
24
+ run_async(
25
+ execute_command(
26
+ app_ctx,
27
+ vin_positional,
28
+ "media_toggle_playback",
29
+ "media.play-pause",
30
+ success_message="Media playback toggled.",
31
+ )
32
+ )
33
+
34
+
35
+ @media_group.command("next-track")
36
+ @click.argument("vin_positional", required=False, default=None, metavar="VIN")
37
+ @global_options
38
+ def next_track_cmd(app_ctx: AppContext, vin_positional: str | None) -> None:
39
+ """Skip to next track."""
40
+ run_async(
41
+ execute_command(
42
+ app_ctx,
43
+ vin_positional,
44
+ "media_next_track",
45
+ "media.next-track",
46
+ success_message="Skipped to next track.",
47
+ )
48
+ )
49
+
50
+
51
+ @media_group.command("prev-track")
52
+ @click.argument("vin_positional", required=False, default=None, metavar="VIN")
53
+ @global_options
54
+ def prev_track_cmd(app_ctx: AppContext, vin_positional: str | None) -> None:
55
+ """Skip to previous track."""
56
+ run_async(
57
+ execute_command(
58
+ app_ctx,
59
+ vin_positional,
60
+ "media_prev_track",
61
+ "media.prev-track",
62
+ success_message="Skipped to previous track.",
63
+ )
64
+ )
65
+
66
+
67
+ @media_group.command("next-fav")
68
+ @click.argument("vin_positional", required=False, default=None, metavar="VIN")
69
+ @global_options
70
+ def next_fav_cmd(app_ctx: AppContext, vin_positional: str | None) -> None:
71
+ """Skip to next favourite."""
72
+ run_async(
73
+ execute_command(
74
+ app_ctx,
75
+ vin_positional,
76
+ "media_next_fav",
77
+ "media.next-fav",
78
+ success_message="Skipped to next favorite.",
79
+ )
80
+ )
81
+
82
+
83
+ @media_group.command("prev-fav")
84
+ @click.argument("vin_positional", required=False, default=None, metavar="VIN")
85
+ @global_options
86
+ def prev_fav_cmd(app_ctx: AppContext, vin_positional: str | None) -> None:
87
+ """Skip to previous favourite."""
88
+ run_async(
89
+ execute_command(
90
+ app_ctx,
91
+ vin_positional,
92
+ "media_prev_fav",
93
+ "media.prev-fav",
94
+ success_message="Skipped to previous favorite.",
95
+ )
96
+ )
97
+
98
+
99
+ @media_group.command("volume-up")
100
+ @click.argument("vin_positional", required=False, default=None, metavar="VIN")
101
+ @global_options
102
+ def volume_up_cmd(app_ctx: AppContext, vin_positional: str | None) -> None:
103
+ """Increase volume by one step."""
104
+ run_async(
105
+ execute_command(
106
+ app_ctx,
107
+ vin_positional,
108
+ "media_volume_up",
109
+ "media.volume-up",
110
+ success_message="Volume increased.",
111
+ )
112
+ )
113
+
114
+
115
+ @media_group.command("volume-down")
116
+ @click.argument("vin_positional", required=False, default=None, metavar="VIN")
117
+ @global_options
118
+ def volume_down_cmd(app_ctx: AppContext, vin_positional: str | None) -> None:
119
+ """Decrease volume by one step."""
120
+ run_async(
121
+ execute_command(
122
+ app_ctx,
123
+ vin_positional,
124
+ "media_volume_down",
125
+ "media.volume-down",
126
+ success_message="Volume decreased.",
127
+ )
128
+ )
129
+
130
+
131
+ @media_group.command("adjust-volume")
132
+ @click.argument("vin_positional", required=False, default=None, metavar="VIN")
133
+ @click.argument("volume", type=click.FloatRange(0.0, 11.0))
134
+ @global_options
135
+ def adjust_volume_cmd(app_ctx: AppContext, vin_positional: str | None, volume: float) -> None:
136
+ """Set volume to VOLUME (0.0-11.0)."""
137
+ run_async(
138
+ execute_command(
139
+ app_ctx,
140
+ vin_positional,
141
+ "adjust_volume",
142
+ "media.adjust-volume",
143
+ body={"volume": volume},
144
+ success_message=f"Volume set to {volume}.",
145
+ )
146
+ )
tescmd/cli/nav.py ADDED
@@ -0,0 +1,242 @@
1
+ """CLI commands for navigation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ import click
8
+
9
+ from tescmd._internal.async_utils import run_async
10
+ from tescmd.cli._client import (
11
+ auto_wake,
12
+ cached_vehicle_data,
13
+ execute_command,
14
+ get_command_api,
15
+ get_vehicle_api,
16
+ invalidate_cache_for_vin,
17
+ require_vin,
18
+ )
19
+ from tescmd.cli._options import global_options
20
+
21
+ if TYPE_CHECKING:
22
+ from tescmd.cli.main import AppContext
23
+
24
+ nav_group = click.Group("nav", help="Navigation commands")
25
+
26
+
27
+ @nav_group.command("send")
28
+ @click.argument("vin_positional", required=False, default=None, metavar="VIN")
29
+ @click.argument("address", nargs=-1, required=True)
30
+ @global_options
31
+ def send_cmd(app_ctx: AppContext, vin_positional: str | None, address: tuple[str, ...]) -> None:
32
+ """Send an address to the vehicle navigation.
33
+
34
+ ADDRESS is the destination address (multiple words allowed).
35
+ """
36
+ full_address = " ".join(address)
37
+ run_async(
38
+ execute_command(
39
+ app_ctx,
40
+ vin_positional,
41
+ "share",
42
+ "nav.send",
43
+ body={"address": full_address},
44
+ success_message="Destination sent to vehicle.",
45
+ )
46
+ )
47
+
48
+
49
+ @nav_group.command("gps")
50
+ @click.argument("vin_positional", required=False, default=None, metavar="VIN")
51
+ @click.argument("coords", nargs=-1, required=True)
52
+ @click.option("--order", type=int, default=None, help="Waypoint order (auto-increments for multi)")
53
+ @global_options
54
+ def gps_cmd(
55
+ app_ctx: AppContext,
56
+ vin_positional: str | None,
57
+ coords: tuple[str, ...],
58
+ order: int | None,
59
+ ) -> None:
60
+ """Navigate to GPS coordinates.
61
+
62
+ Accepts coordinates as LAT LON pairs or as comma-separated LAT,LON strings.
63
+
64
+ Single point::
65
+
66
+ tescmd nav gps 37.7749 122.4194
67
+ tescmd nav gps 37.7749,122.4194
68
+ tescmd nav gps 37.7749 122.4194 --order 1
69
+
70
+ Multi-stop route (auto-ordered)::
71
+
72
+ tescmd nav gps 37.7749,122.4194 37.3382,121.8863
73
+ """
74
+ run_async(_cmd_gps(app_ctx, vin_positional, coords, order))
75
+
76
+
77
+ def _parse_coords(coords: tuple[str, ...]) -> list[tuple[float, float]]:
78
+ """Parse coordinate arguments into (lat, lon) pairs.
79
+
80
+ Accepts either space-separated ``LAT LON`` pairs or comma-separated
81
+ ``LAT,LON`` strings (or a mix).
82
+ """
83
+ points: list[tuple[float, float]] = []
84
+ i = 0
85
+ while i < len(coords):
86
+ token = coords[i]
87
+ if "," in token:
88
+ parts = token.split(",", 1)
89
+ points.append((float(parts[0]), float(parts[1])))
90
+ i += 1
91
+ elif i + 1 < len(coords) and "," not in coords[i + 1]:
92
+ points.append((float(token), float(coords[i + 1])))
93
+ i += 2
94
+ else:
95
+ raise click.UsageError(f"Invalid coordinate: {token!r}. Use 'LAT LON' or 'LAT,LON'.")
96
+ return points
97
+
98
+
99
+ async def _cmd_gps(
100
+ app_ctx: AppContext,
101
+ vin_positional: str | None,
102
+ coords: tuple[str, ...],
103
+ order: int | None,
104
+ ) -> None:
105
+ points = _parse_coords(coords)
106
+
107
+ if len(points) == 1:
108
+ lat, lon = points[0]
109
+ body: dict[str, object] = {"lat": lat, "lon": lon}
110
+ if order is not None:
111
+ body["order"] = order
112
+ await execute_command(
113
+ app_ctx,
114
+ vin_positional,
115
+ "navigation_gps_request",
116
+ "nav.gps",
117
+ body=body,
118
+ success_message="GPS coordinates sent to vehicle.",
119
+ )
120
+ else:
121
+ # Multi-point: send each with auto-incrementing order
122
+ start_order = order if order is not None else 1
123
+ for idx, (lat, lon) in enumerate(points):
124
+ await execute_command(
125
+ app_ctx,
126
+ vin_positional,
127
+ "navigation_gps_request",
128
+ "nav.gps",
129
+ body={"lat": lat, "lon": lon, "order": start_order + idx},
130
+ success_message=f"GPS waypoint {start_order + idx} sent to vehicle.",
131
+ )
132
+
133
+
134
+ @nav_group.command("supercharger")
135
+ @click.argument("vin_positional", required=False, default=None, metavar="VIN")
136
+ @global_options
137
+ def supercharger_cmd(app_ctx: AppContext, vin_positional: str | None) -> None:
138
+ """Navigate to the nearest Supercharger."""
139
+ run_async(
140
+ execute_command(
141
+ app_ctx,
142
+ vin_positional,
143
+ "navigation_sc_request",
144
+ "nav.supercharger",
145
+ success_message="Navigating to nearest Supercharger.",
146
+ )
147
+ )
148
+
149
+
150
+ @nav_group.command("homelink")
151
+ @click.argument("vin_positional", required=False, default=None, metavar="VIN")
152
+ @click.option(
153
+ "--lat", type=float, default=None, help="Latitude (auto-detected from vehicle if omitted)"
154
+ )
155
+ @click.option(
156
+ "--lon", type=float, default=None, help="Longitude (auto-detected from vehicle if omitted)"
157
+ )
158
+ @global_options
159
+ def homelink_cmd(
160
+ app_ctx: AppContext, vin_positional: str | None, lat: float | None, lon: float | None
161
+ ) -> None:
162
+ """Trigger HomeLink (garage door)."""
163
+ run_async(_cmd_homelink(app_ctx, vin_positional, lat, lon))
164
+
165
+
166
+ async def _cmd_homelink(
167
+ app_ctx: AppContext,
168
+ vin_positional: str | None,
169
+ lat: float | None,
170
+ lon: float | None,
171
+ ) -> None:
172
+ formatter = app_ctx.formatter
173
+ vin = require_vin(vin_positional, app_ctx.vin)
174
+
175
+ # Auto-detect coordinates from vehicle drive_state if not provided
176
+ if lat is None or lon is None:
177
+ client, vehicle_api = get_vehicle_api(app_ctx)
178
+ try:
179
+ vdata = await cached_vehicle_data(app_ctx, vehicle_api, vin, endpoints=["drive_state"])
180
+ finally:
181
+ await client.close()
182
+
183
+ ds = vdata.drive_state
184
+ if ds and ds.latitude is not None and ds.longitude is not None:
185
+ lat = ds.latitude
186
+ lon = ds.longitude
187
+ else:
188
+ from tescmd.api.errors import ConfigError
189
+
190
+ raise ConfigError(
191
+ "Cannot detect vehicle location. Provide --lat and --lon explicitly."
192
+ )
193
+
194
+ client, vehicle_api, cmd_api = get_command_api(app_ctx)
195
+ try:
196
+ result = await auto_wake(
197
+ formatter,
198
+ vehicle_api,
199
+ vin,
200
+ lambda: cmd_api.trigger_homelink(vin, lat=lat, lon=lon),
201
+ auto=app_ctx.auto_wake,
202
+ )
203
+ finally:
204
+ await client.close()
205
+
206
+ invalidate_cache_for_vin(app_ctx, vin)
207
+
208
+ if formatter.format == "json":
209
+ formatter.output(result, command="nav.homelink")
210
+ else:
211
+ msg = result.response.reason or "HomeLink triggered."
212
+ formatter.rich.command_result(result.response.result, msg)
213
+
214
+
215
+ @nav_group.command("waypoints")
216
+ @click.argument("vin_positional", required=False, default=None, metavar="VIN")
217
+ @click.argument("place_ids", nargs=-1, required=True)
218
+ @global_options
219
+ def waypoints_cmd(
220
+ app_ctx: AppContext, vin_positional: str | None, place_ids: tuple[str, ...]
221
+ ) -> None:
222
+ """Send multi-stop waypoints using Google Place IDs.
223
+
224
+ PLACE_IDS are one or more Google Maps Place IDs. Each ID is prefixed
225
+ with ``refId:`` and joined into a comma-separated string before being
226
+ sent to the vehicle.
227
+
228
+ Example::
229
+
230
+ tescmd nav waypoints ChIJIQBpAG2ahYAR_6128GcTUEo ChIJw____96GhYARCVVwg5cT7c0
231
+ """
232
+ waypoints_str = ",".join(f"refId:{pid}" for pid in place_ids)
233
+ run_async(
234
+ execute_command(
235
+ app_ctx,
236
+ vin_positional,
237
+ "navigation_waypoints_request",
238
+ "nav.waypoints",
239
+ body={"waypoints": waypoints_str},
240
+ success_message="Waypoints sent to vehicle.",
241
+ )
242
+ )
tescmd/cli/partner.py ADDED
@@ -0,0 +1,112 @@
1
+ """CLI commands for partner account endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ import click
8
+
9
+ from tescmd._internal.async_utils import run_async
10
+ from tescmd.cli._client import TTL_SLOW, TTL_STATIC, cached_api_call, get_partner_api
11
+ from tescmd.cli._options import global_options
12
+
13
+ if TYPE_CHECKING:
14
+ from tescmd.cli.main import AppContext
15
+
16
+ partner_group = click.Group(
17
+ "partner", help="Partner account endpoints (require client credentials)"
18
+ )
19
+
20
+
21
+ @partner_group.command("public-key")
22
+ @click.option("--domain", required=True, help="Domain to look up")
23
+ @global_options
24
+ def public_key_cmd(app_ctx: AppContext, domain: str) -> None:
25
+ """Get the public key registered for DOMAIN."""
26
+ run_async(_cmd_public_key(app_ctx, domain))
27
+
28
+
29
+ async def _cmd_public_key(app_ctx: AppContext, domain: str) -> None:
30
+ formatter = app_ctx.formatter
31
+ client, api = await get_partner_api(app_ctx)
32
+ try:
33
+ result = await cached_api_call(
34
+ app_ctx,
35
+ scope="partner",
36
+ identifier=domain,
37
+ endpoint="partner.public-key",
38
+ fetch=lambda: api.public_key(domain=domain),
39
+ ttl=TTL_STATIC,
40
+ )
41
+ finally:
42
+ await client.close()
43
+
44
+ if formatter.format == "json":
45
+ formatter.output(result, command="partner.public-key")
46
+ else:
47
+ formatter.rich._dict_table("Public Key", result)
48
+
49
+
50
+ @partner_group.command("telemetry-error-vins")
51
+ @global_options
52
+ def telemetry_error_vins_cmd(app_ctx: AppContext) -> None:
53
+ """List VINs with recent fleet telemetry errors."""
54
+ run_async(_cmd_telemetry_error_vins(app_ctx))
55
+
56
+
57
+ async def _cmd_telemetry_error_vins(app_ctx: AppContext) -> None:
58
+ formatter = app_ctx.formatter
59
+ client, api = await get_partner_api(app_ctx)
60
+ try:
61
+ result = await cached_api_call(
62
+ app_ctx,
63
+ scope="partner",
64
+ identifier="global",
65
+ endpoint="partner.telemetry-error-vins",
66
+ fetch=lambda: api.fleet_telemetry_error_vins(),
67
+ ttl=TTL_SLOW,
68
+ )
69
+ finally:
70
+ await client.close()
71
+
72
+ if formatter.format == "json":
73
+ formatter.output(result, command="partner.telemetry-error-vins")
74
+ else:
75
+ if result:
76
+ formatter.rich.info("VINs with telemetry errors:")
77
+ for vin in result:
78
+ formatter.rich.info(f" {vin}")
79
+ else:
80
+ formatter.rich.info("[dim]No VINs with telemetry errors.[/dim]")
81
+
82
+
83
+ @partner_group.command("telemetry-errors")
84
+ @global_options
85
+ def telemetry_errors_cmd(app_ctx: AppContext) -> None:
86
+ """Get recent fleet telemetry errors across all vehicles."""
87
+ run_async(_cmd_telemetry_errors(app_ctx))
88
+
89
+
90
+ async def _cmd_telemetry_errors(app_ctx: AppContext) -> None:
91
+ formatter = app_ctx.formatter
92
+ client, api = await get_partner_api(app_ctx)
93
+ try:
94
+ result = await cached_api_call(
95
+ app_ctx,
96
+ scope="partner",
97
+ identifier="global",
98
+ endpoint="partner.telemetry-errors",
99
+ fetch=lambda: api.fleet_telemetry_errors(),
100
+ ttl=TTL_SLOW,
101
+ )
102
+ finally:
103
+ await client.close()
104
+
105
+ if formatter.format == "json":
106
+ formatter.output(result, command="partner.telemetry-errors")
107
+ else:
108
+ if result:
109
+ for err in result:
110
+ formatter.rich._dict_table("Telemetry Error", err)
111
+ else:
112
+ formatter.rich.info("[dim]No recent telemetry errors.[/dim]")
tescmd/cli/raw.py ADDED
@@ -0,0 +1,75 @@
1
+ """CLI commands for raw Fleet API access (power-user escape hatch)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import TYPE_CHECKING
7
+
8
+ import click
9
+
10
+ from tescmd._internal.async_utils import run_async
11
+ from tescmd.cli._client import get_client
12
+ from tescmd.cli._options import global_options
13
+
14
+ if TYPE_CHECKING:
15
+ from tescmd.cli.main import AppContext
16
+
17
+ raw_group = click.Group("raw", help="Raw Fleet API access")
18
+
19
+
20
+ @raw_group.command("get")
21
+ @click.argument("path")
22
+ @click.option("--params", default=None, help="Query parameters as JSON string")
23
+ @global_options
24
+ def get_cmd(app_ctx: AppContext, path: str, params: str | None) -> None:
25
+ """Send a raw GET request to PATH.
26
+
27
+ Example: tescmd raw get /api/1/vehicles
28
+ """
29
+ run_async(_cmd_get(app_ctx, path, params))
30
+
31
+
32
+ async def _cmd_get(app_ctx: AppContext, path: str, params_json: str | None) -> None:
33
+ formatter = app_ctx.formatter
34
+ client = get_client(app_ctx)
35
+ try:
36
+ kwargs: dict[str, object] = {}
37
+ if params_json:
38
+ kwargs["params"] = json.loads(params_json)
39
+ data = await client.get(path, **kwargs)
40
+ finally:
41
+ await client.close()
42
+
43
+ if formatter.format == "json":
44
+ formatter.output(data, command="raw.get")
45
+ else:
46
+ formatter.rich.info(json.dumps(data, indent=2, default=str))
47
+
48
+
49
+ @raw_group.command("post")
50
+ @click.argument("path")
51
+ @click.option("--body", default=None, help="Request body as JSON string")
52
+ @global_options
53
+ def post_cmd(app_ctx: AppContext, path: str, body: str | None) -> None:
54
+ """Send a raw POST request to PATH.
55
+
56
+ Example: tescmd raw post /api/1/vehicles/VIN/command/flash_lights
57
+ """
58
+ run_async(_cmd_post(app_ctx, path, body))
59
+
60
+
61
+ async def _cmd_post(app_ctx: AppContext, path: str, body_json: str | None) -> None:
62
+ formatter = app_ctx.formatter
63
+ client = get_client(app_ctx)
64
+ try:
65
+ kwargs: dict[str, object] = {}
66
+ if body_json:
67
+ kwargs["json"] = json.loads(body_json)
68
+ data = await client.post(path, **kwargs)
69
+ finally:
70
+ await client.close()
71
+
72
+ if formatter.format == "json":
73
+ formatter.output(data, command="raw.post")
74
+ else:
75
+ formatter.rich.info(json.dumps(data, indent=2, default=str))