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.
- tescmd/__init__.py +3 -0
- tescmd/__main__.py +5 -0
- tescmd/_internal/__init__.py +0 -0
- tescmd/_internal/async_utils.py +25 -0
- tescmd/_internal/permissions.py +43 -0
- tescmd/_internal/vin.py +44 -0
- tescmd/api/__init__.py +1 -0
- tescmd/api/charging.py +102 -0
- tescmd/api/client.py +189 -0
- tescmd/api/command.py +540 -0
- tescmd/api/energy.py +146 -0
- tescmd/api/errors.py +76 -0
- tescmd/api/partner.py +40 -0
- tescmd/api/sharing.py +65 -0
- tescmd/api/signed_command.py +277 -0
- tescmd/api/user.py +38 -0
- tescmd/api/vehicle.py +150 -0
- tescmd/auth/__init__.py +1 -0
- tescmd/auth/oauth.py +312 -0
- tescmd/auth/server.py +108 -0
- tescmd/auth/token_store.py +273 -0
- tescmd/ble/__init__.py +0 -0
- tescmd/cache/__init__.py +6 -0
- tescmd/cache/keys.py +51 -0
- tescmd/cache/response_cache.py +213 -0
- tescmd/cli/__init__.py +0 -0
- tescmd/cli/_client.py +603 -0
- tescmd/cli/_options.py +126 -0
- tescmd/cli/auth.py +682 -0
- tescmd/cli/billing.py +240 -0
- tescmd/cli/cache.py +85 -0
- tescmd/cli/charge.py +610 -0
- tescmd/cli/climate.py +501 -0
- tescmd/cli/energy.py +385 -0
- tescmd/cli/key.py +611 -0
- tescmd/cli/main.py +601 -0
- tescmd/cli/media.py +146 -0
- tescmd/cli/nav.py +242 -0
- tescmd/cli/partner.py +112 -0
- tescmd/cli/raw.py +75 -0
- tescmd/cli/security.py +495 -0
- tescmd/cli/setup.py +786 -0
- tescmd/cli/sharing.py +188 -0
- tescmd/cli/software.py +81 -0
- tescmd/cli/status.py +106 -0
- tescmd/cli/trunk.py +240 -0
- tescmd/cli/user.py +145 -0
- tescmd/cli/vehicle.py +837 -0
- tescmd/config/__init__.py +0 -0
- tescmd/crypto/__init__.py +19 -0
- tescmd/crypto/ecdh.py +46 -0
- tescmd/crypto/keys.py +122 -0
- tescmd/deploy/__init__.py +0 -0
- tescmd/deploy/github_pages.py +268 -0
- tescmd/models/__init__.py +85 -0
- tescmd/models/auth.py +108 -0
- tescmd/models/command.py +18 -0
- tescmd/models/config.py +63 -0
- tescmd/models/energy.py +56 -0
- tescmd/models/sharing.py +26 -0
- tescmd/models/user.py +37 -0
- tescmd/models/vehicle.py +185 -0
- tescmd/output/__init__.py +5 -0
- tescmd/output/formatter.py +132 -0
- tescmd/output/json_output.py +83 -0
- tescmd/output/rich_output.py +809 -0
- tescmd/protocol/__init__.py +23 -0
- tescmd/protocol/commands.py +175 -0
- tescmd/protocol/encoder.py +122 -0
- tescmd/protocol/metadata.py +116 -0
- tescmd/protocol/payloads.py +621 -0
- tescmd/protocol/protobuf/__init__.py +6 -0
- tescmd/protocol/protobuf/messages.py +564 -0
- tescmd/protocol/session.py +318 -0
- tescmd/protocol/signer.py +84 -0
- tescmd/py.typed +0 -0
- tescmd-0.1.2.dist-info/METADATA +458 -0
- tescmd-0.1.2.dist-info/RECORD +81 -0
- tescmd-0.1.2.dist-info/WHEEL +4 -0
- tescmd-0.1.2.dist-info/entry_points.txt +2 -0
- 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))
|