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/sharing.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""CLI commands for vehicle sharing (drivers and invites)."""
|
|
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
|
+
TTL_SLOW,
|
|
12
|
+
cached_api_call,
|
|
13
|
+
get_sharing_api,
|
|
14
|
+
invalidate_cache_for_vin,
|
|
15
|
+
require_vin,
|
|
16
|
+
)
|
|
17
|
+
from tescmd.cli._options import global_options
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from tescmd.cli.main import AppContext
|
|
21
|
+
|
|
22
|
+
sharing_group = click.Group("sharing", help="Vehicle sharing commands")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@sharing_group.command("add-driver")
|
|
26
|
+
@click.argument("vin_positional", required=False, default=None, metavar="VIN")
|
|
27
|
+
@click.argument("email")
|
|
28
|
+
@global_options
|
|
29
|
+
def add_driver_cmd(app_ctx: AppContext, vin_positional: str | None, email: str) -> None:
|
|
30
|
+
"""Add a driver by EMAIL."""
|
|
31
|
+
run_async(_cmd_add_driver(app_ctx, vin_positional, email))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def _cmd_add_driver(app_ctx: AppContext, vin_positional: str | None, email: str) -> None:
|
|
35
|
+
formatter = app_ctx.formatter
|
|
36
|
+
vin = require_vin(vin_positional, app_ctx.vin)
|
|
37
|
+
client, api = get_sharing_api(app_ctx)
|
|
38
|
+
try:
|
|
39
|
+
result = await api.add_driver(vin, email=email)
|
|
40
|
+
finally:
|
|
41
|
+
await client.close()
|
|
42
|
+
|
|
43
|
+
invalidate_cache_for_vin(app_ctx, vin)
|
|
44
|
+
|
|
45
|
+
if formatter.format == "json":
|
|
46
|
+
formatter.output(result, command="sharing.add-driver")
|
|
47
|
+
else:
|
|
48
|
+
formatter.rich.info(f"Driver invite sent to {email}")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@sharing_group.command("remove-driver")
|
|
52
|
+
@click.argument("vin_positional", required=False, default=None, metavar="VIN")
|
|
53
|
+
@click.argument("share_user_id", type=int)
|
|
54
|
+
@global_options
|
|
55
|
+
def remove_driver_cmd(app_ctx: AppContext, vin_positional: str | None, share_user_id: int) -> None:
|
|
56
|
+
"""Remove a driver by SHARE_USER_ID."""
|
|
57
|
+
run_async(_cmd_remove_driver(app_ctx, vin_positional, share_user_id))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def _cmd_remove_driver(
|
|
61
|
+
app_ctx: AppContext, vin_positional: str | None, share_user_id: int
|
|
62
|
+
) -> None:
|
|
63
|
+
formatter = app_ctx.formatter
|
|
64
|
+
vin = require_vin(vin_positional, app_ctx.vin)
|
|
65
|
+
client, api = get_sharing_api(app_ctx)
|
|
66
|
+
try:
|
|
67
|
+
result = await api.remove_driver(vin, share_user_id=share_user_id)
|
|
68
|
+
finally:
|
|
69
|
+
await client.close()
|
|
70
|
+
|
|
71
|
+
invalidate_cache_for_vin(app_ctx, vin)
|
|
72
|
+
|
|
73
|
+
if formatter.format == "json":
|
|
74
|
+
formatter.output(result, command="sharing.remove-driver")
|
|
75
|
+
else:
|
|
76
|
+
formatter.rich.info("Driver removed")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@sharing_group.command("create-invite")
|
|
80
|
+
@click.argument("vin_positional", required=False, default=None, metavar="VIN")
|
|
81
|
+
@global_options
|
|
82
|
+
def create_invite_cmd(app_ctx: AppContext, vin_positional: str | None) -> None:
|
|
83
|
+
"""Create a vehicle share invite."""
|
|
84
|
+
run_async(_cmd_create_invite(app_ctx, vin_positional))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async def _cmd_create_invite(app_ctx: AppContext, vin_positional: str | None) -> None:
|
|
88
|
+
formatter = app_ctx.formatter
|
|
89
|
+
vin = require_vin(vin_positional, app_ctx.vin)
|
|
90
|
+
client, api = get_sharing_api(app_ctx)
|
|
91
|
+
try:
|
|
92
|
+
result = await api.create_invite(vin)
|
|
93
|
+
finally:
|
|
94
|
+
await client.close()
|
|
95
|
+
|
|
96
|
+
invalidate_cache_for_vin(app_ctx, vin)
|
|
97
|
+
|
|
98
|
+
if formatter.format == "json":
|
|
99
|
+
formatter.output(result, command="sharing.create-invite")
|
|
100
|
+
else:
|
|
101
|
+
code = result.code or ""
|
|
102
|
+
formatter.rich.info(f"Invite created: {code}" if code else "Invite created")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@sharing_group.command("redeem-invite")
|
|
106
|
+
@click.argument("code")
|
|
107
|
+
@global_options
|
|
108
|
+
def redeem_invite_cmd(app_ctx: AppContext, code: str) -> None:
|
|
109
|
+
"""Redeem a vehicle share invite CODE (no VIN required)."""
|
|
110
|
+
run_async(_cmd_redeem_invite(app_ctx, code))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
async def _cmd_redeem_invite(app_ctx: AppContext, code: str) -> None:
|
|
114
|
+
formatter = app_ctx.formatter
|
|
115
|
+
client, api = get_sharing_api(app_ctx)
|
|
116
|
+
try:
|
|
117
|
+
result = await api.redeem_invite(code=code)
|
|
118
|
+
finally:
|
|
119
|
+
await client.close()
|
|
120
|
+
|
|
121
|
+
if formatter.format == "json":
|
|
122
|
+
formatter.output(result, command="sharing.redeem-invite")
|
|
123
|
+
else:
|
|
124
|
+
formatter.rich.info("Invite redeemed")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@sharing_group.command("revoke-invite")
|
|
128
|
+
@click.argument("vin_positional", required=False, default=None, metavar="VIN")
|
|
129
|
+
@click.argument("invite_id")
|
|
130
|
+
@global_options
|
|
131
|
+
def revoke_invite_cmd(app_ctx: AppContext, vin_positional: str | None, invite_id: str) -> None:
|
|
132
|
+
"""Revoke a vehicle share invite by ID."""
|
|
133
|
+
run_async(_cmd_revoke_invite(app_ctx, vin_positional, invite_id))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
async def _cmd_revoke_invite(
|
|
137
|
+
app_ctx: AppContext, vin_positional: str | None, invite_id: str
|
|
138
|
+
) -> None:
|
|
139
|
+
formatter = app_ctx.formatter
|
|
140
|
+
vin = require_vin(vin_positional, app_ctx.vin)
|
|
141
|
+
client, api = get_sharing_api(app_ctx)
|
|
142
|
+
try:
|
|
143
|
+
result = await api.revoke_invite(vin, invite_id=invite_id)
|
|
144
|
+
finally:
|
|
145
|
+
await client.close()
|
|
146
|
+
|
|
147
|
+
invalidate_cache_for_vin(app_ctx, vin)
|
|
148
|
+
|
|
149
|
+
if formatter.format == "json":
|
|
150
|
+
formatter.output(result, command="sharing.revoke-invite")
|
|
151
|
+
else:
|
|
152
|
+
formatter.rich.info("Invite revoked")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@sharing_group.command("list-invites")
|
|
156
|
+
@click.argument("vin_positional", required=False, default=None, metavar="VIN")
|
|
157
|
+
@global_options
|
|
158
|
+
def list_invites_cmd(app_ctx: AppContext, vin_positional: str | None) -> None:
|
|
159
|
+
"""List active vehicle share invites."""
|
|
160
|
+
run_async(_cmd_list_invites(app_ctx, vin_positional))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
async def _cmd_list_invites(app_ctx: AppContext, vin_positional: str | None) -> None:
|
|
164
|
+
formatter = app_ctx.formatter
|
|
165
|
+
vin = require_vin(vin_positional, app_ctx.vin)
|
|
166
|
+
client, api = get_sharing_api(app_ctx)
|
|
167
|
+
try:
|
|
168
|
+
invites = await cached_api_call(
|
|
169
|
+
app_ctx,
|
|
170
|
+
scope="vin",
|
|
171
|
+
identifier=vin,
|
|
172
|
+
endpoint="sharing.list-invites",
|
|
173
|
+
fetch=lambda: api.list_invites(vin),
|
|
174
|
+
ttl=TTL_SLOW,
|
|
175
|
+
)
|
|
176
|
+
finally:
|
|
177
|
+
await client.close()
|
|
178
|
+
|
|
179
|
+
if formatter.format == "json":
|
|
180
|
+
formatter.output(invites, command="sharing.list-invites")
|
|
181
|
+
else:
|
|
182
|
+
if invites:
|
|
183
|
+
for inv in invites:
|
|
184
|
+
inv_id = (inv.get("id") if isinstance(inv, dict) else inv.id) or ""
|
|
185
|
+
inv_code = (inv.get("code") if isinstance(inv, dict) else inv.code) or ""
|
|
186
|
+
formatter.rich.info(f" ID: {inv_id} Code: {inv_code}")
|
|
187
|
+
else:
|
|
188
|
+
formatter.rich.info("[dim]No active invites.[/dim]")
|
tescmd/cli/software.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""CLI commands for software update management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from tescmd._internal.async_utils import run_async
|
|
10
|
+
from tescmd.cli._client import cached_vehicle_data, execute_command, get_vehicle_api, require_vin
|
|
11
|
+
from tescmd.cli._options import global_options
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from tescmd.cli.main import AppContext
|
|
15
|
+
|
|
16
|
+
software_group = click.Group("software", help="Software update commands")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@software_group.command("status")
|
|
20
|
+
@click.argument("vin_positional", required=False, default=None, metavar="VIN")
|
|
21
|
+
@global_options
|
|
22
|
+
def status_cmd(app_ctx: AppContext, vin_positional: str | None) -> None:
|
|
23
|
+
"""Show current software version and update status."""
|
|
24
|
+
run_async(_cmd_status(app_ctx, vin_positional))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def _cmd_status(app_ctx: AppContext, vin_positional: str | None) -> None:
|
|
28
|
+
formatter = app_ctx.formatter
|
|
29
|
+
vin = require_vin(vin_positional, app_ctx.vin)
|
|
30
|
+
client, api = get_vehicle_api(app_ctx)
|
|
31
|
+
try:
|
|
32
|
+
vdata = await cached_vehicle_data(app_ctx, api, vin, endpoints=["vehicle_state"])
|
|
33
|
+
finally:
|
|
34
|
+
await client.close()
|
|
35
|
+
|
|
36
|
+
if formatter.format == "json":
|
|
37
|
+
vs = vdata.vehicle_state
|
|
38
|
+
data: dict[str, Any] = {}
|
|
39
|
+
if vs:
|
|
40
|
+
data["car_version"] = vs.car_version
|
|
41
|
+
data["software_update"] = vs.software_update
|
|
42
|
+
formatter.output(data, command="software.status")
|
|
43
|
+
else:
|
|
44
|
+
if vdata.vehicle_state:
|
|
45
|
+
formatter.rich.software_status(vdata.vehicle_state)
|
|
46
|
+
else:
|
|
47
|
+
formatter.rich.info("No vehicle state data available.")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@software_group.command("schedule")
|
|
51
|
+
@click.argument("vin_positional", required=False, default=None, metavar="VIN")
|
|
52
|
+
@click.argument("seconds", type=int)
|
|
53
|
+
@global_options
|
|
54
|
+
def schedule_cmd(app_ctx: AppContext, vin_positional: str | None, seconds: int) -> None:
|
|
55
|
+
"""Schedule a software update to install in SECONDS from now."""
|
|
56
|
+
run_async(
|
|
57
|
+
execute_command(
|
|
58
|
+
app_ctx,
|
|
59
|
+
vin_positional,
|
|
60
|
+
"schedule_software_update",
|
|
61
|
+
"software.schedule",
|
|
62
|
+
body={"offset_sec": seconds},
|
|
63
|
+
success_message=f"Software update scheduled in {seconds}s.",
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@software_group.command("cancel")
|
|
69
|
+
@click.argument("vin_positional", required=False, default=None, metavar="VIN")
|
|
70
|
+
@global_options
|
|
71
|
+
def cancel_cmd(app_ctx: AppContext, vin_positional: str | None) -> None:
|
|
72
|
+
"""Cancel a pending software update."""
|
|
73
|
+
run_async(
|
|
74
|
+
execute_command(
|
|
75
|
+
app_ctx,
|
|
76
|
+
vin_positional,
|
|
77
|
+
"cancel_software_update",
|
|
78
|
+
"software.cancel",
|
|
79
|
+
success_message="Software update cancelled.",
|
|
80
|
+
)
|
|
81
|
+
)
|
tescmd/cli/status.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""CLI command for showing current configuration status."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from tescmd.auth.token_store import TokenStore
|
|
12
|
+
from tescmd.cli._client import get_cache
|
|
13
|
+
from tescmd.cli._options import global_options
|
|
14
|
+
from tescmd.models.config import AppSettings
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from tescmd.cli.main import AppContext
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@click.command("status")
|
|
21
|
+
@global_options
|
|
22
|
+
def status_cmd(app_ctx: AppContext) -> None:
|
|
23
|
+
"""Show current configuration, authentication, and cache status."""
|
|
24
|
+
formatter = app_ctx.formatter
|
|
25
|
+
settings = AppSettings()
|
|
26
|
+
store = TokenStore(
|
|
27
|
+
profile=app_ctx.profile,
|
|
28
|
+
token_file=settings.token_file,
|
|
29
|
+
config_dir=settings.config_dir,
|
|
30
|
+
)
|
|
31
|
+
cache = get_cache(app_ctx)
|
|
32
|
+
cache_info = cache.status()
|
|
33
|
+
|
|
34
|
+
# Auth info
|
|
35
|
+
has_token = store.has_token
|
|
36
|
+
meta = store.metadata or {}
|
|
37
|
+
expires_at = meta.get("expires_at", 0.0)
|
|
38
|
+
expires_in = max(0, int(expires_at - time.time())) if has_token else 0
|
|
39
|
+
has_refresh = store.refresh_token is not None
|
|
40
|
+
|
|
41
|
+
# Key info
|
|
42
|
+
key_dir = Path(settings.config_dir).expanduser() / "keys"
|
|
43
|
+
key_count = len(list(key_dir.glob("*.pem"))) // 2 if key_dir.is_dir() else 0
|
|
44
|
+
|
|
45
|
+
# Mask client ID
|
|
46
|
+
cid = settings.client_id
|
|
47
|
+
client_id_display = (cid[:8] + "\u2026") if cid and len(cid) > 8 else (cid or "not set")
|
|
48
|
+
|
|
49
|
+
data = {
|
|
50
|
+
"profile": app_ctx.profile,
|
|
51
|
+
"region": settings.region,
|
|
52
|
+
"vin": settings.vin,
|
|
53
|
+
"setup_tier": settings.setup_tier,
|
|
54
|
+
"domain": settings.domain,
|
|
55
|
+
"client_id": client_id_display,
|
|
56
|
+
"authenticated": has_token,
|
|
57
|
+
"expires_in": expires_in if has_token else None,
|
|
58
|
+
"has_refresh_token": has_refresh,
|
|
59
|
+
"cache_enabled": cache_info["enabled"],
|
|
60
|
+
"cache_ttl": cache_info["default_ttl"],
|
|
61
|
+
"cache_entries": cache_info["total"],
|
|
62
|
+
"cache_fresh": cache_info["fresh"],
|
|
63
|
+
"cache_stale": cache_info["stale"],
|
|
64
|
+
"config_dir": settings.config_dir,
|
|
65
|
+
"cache_dir": settings.cache_dir,
|
|
66
|
+
"key_pairs": key_count,
|
|
67
|
+
"token_backend": store.backend_name,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if formatter.format == "json":
|
|
71
|
+
formatter.output(data, command="status")
|
|
72
|
+
else:
|
|
73
|
+
# Profile & API
|
|
74
|
+
formatter.rich.info(f"Profile: {data['profile']}")
|
|
75
|
+
formatter.rich.info(f"Region: {data['region']}")
|
|
76
|
+
formatter.rich.info(f"VIN: {data['vin'] or '[dim]not set[/dim]'}")
|
|
77
|
+
formatter.rich.info(f"Setup tier: {data['setup_tier'] or '[dim]not set[/dim]'}")
|
|
78
|
+
formatter.rich.info(f"Domain: {data['domain'] or '[dim]not set[/dim]'}")
|
|
79
|
+
formatter.rich.info(f"Client ID: {data['client_id']}")
|
|
80
|
+
formatter.rich.info("")
|
|
81
|
+
|
|
82
|
+
# Auth
|
|
83
|
+
formatter.rich.info(f"Token store: {data['token_backend']}")
|
|
84
|
+
auth_str = "[green]authenticated[/green]" if has_token else "[red]not authenticated[/red]"
|
|
85
|
+
formatter.rich.info(f"Auth: {auth_str}")
|
|
86
|
+
if has_token:
|
|
87
|
+
formatter.rich.info(f"Expires in: {expires_in}s")
|
|
88
|
+
refresh_str = "[green]yes[/green]" if has_refresh else "[yellow]no[/yellow]"
|
|
89
|
+
formatter.rich.info(f"Refresh: {refresh_str}")
|
|
90
|
+
formatter.rich.info("")
|
|
91
|
+
|
|
92
|
+
# Cache
|
|
93
|
+
cache_str = "[green]enabled[/green]" if data["cache_enabled"] else "[red]disabled[/red]"
|
|
94
|
+
formatter.rich.info(f"Cache: {cache_str}")
|
|
95
|
+
formatter.rich.info(f"TTL: {data['cache_ttl']}s")
|
|
96
|
+
formatter.rich.info(
|
|
97
|
+
f"Entries: {data['cache_entries']}"
|
|
98
|
+
f" ({data['cache_fresh']} fresh, {data['cache_stale']} stale)"
|
|
99
|
+
)
|
|
100
|
+
formatter.rich.info("")
|
|
101
|
+
|
|
102
|
+
# Paths
|
|
103
|
+
formatter.rich.info(f"Config dir: {data['config_dir']}")
|
|
104
|
+
formatter.rich.info(f"Cache dir: {data['cache_dir']}")
|
|
105
|
+
key_str = f"{key_dir} ({key_count} key pair{'s' if key_count != 1 else ''})"
|
|
106
|
+
formatter.rich.info(f"Keys: {key_str}")
|
tescmd/cli/trunk.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""CLI commands for trunk and window operations."""
|
|
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
|
+
execute_command,
|
|
13
|
+
get_command_api,
|
|
14
|
+
invalidate_cache_for_vin,
|
|
15
|
+
require_vin,
|
|
16
|
+
)
|
|
17
|
+
from tescmd.cli._options import global_options
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from tescmd.cli.main import AppContext
|
|
21
|
+
from tescmd.models.command import CommandResponse
|
|
22
|
+
|
|
23
|
+
trunk_group = click.Group("trunk", help="Trunk and window commands")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# Trunk commands
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@trunk_group.command("open")
|
|
32
|
+
@click.argument("vin_positional", required=False, default=None, metavar="VIN")
|
|
33
|
+
@global_options
|
|
34
|
+
def open_cmd(app_ctx: AppContext, vin_positional: str | None) -> None:
|
|
35
|
+
"""Open (toggle) the rear trunk."""
|
|
36
|
+
run_async(
|
|
37
|
+
execute_command(
|
|
38
|
+
app_ctx,
|
|
39
|
+
vin_positional,
|
|
40
|
+
"actuate_trunk",
|
|
41
|
+
"trunk.open",
|
|
42
|
+
body={"which_trunk": "rear"},
|
|
43
|
+
success_message="Rear trunk opened.",
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@trunk_group.command("close")
|
|
49
|
+
@click.argument("vin_positional", required=False, default=None, metavar="VIN")
|
|
50
|
+
@global_options
|
|
51
|
+
def close_cmd(app_ctx: AppContext, vin_positional: str | None) -> None:
|
|
52
|
+
"""Close (toggle) the rear trunk.
|
|
53
|
+
|
|
54
|
+
Note: actuate_trunk is a toggle — open and close both call the same endpoint.
|
|
55
|
+
"""
|
|
56
|
+
run_async(
|
|
57
|
+
execute_command(
|
|
58
|
+
app_ctx,
|
|
59
|
+
vin_positional,
|
|
60
|
+
"actuate_trunk",
|
|
61
|
+
"trunk.close",
|
|
62
|
+
body={"which_trunk": "rear"},
|
|
63
|
+
success_message="Rear trunk closed.",
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@trunk_group.command("frunk")
|
|
69
|
+
@click.argument("vin_positional", required=False, default=None, metavar="VIN")
|
|
70
|
+
@global_options
|
|
71
|
+
def frunk_cmd(app_ctx: AppContext, vin_positional: str | None) -> None:
|
|
72
|
+
"""Open the front trunk (frunk)."""
|
|
73
|
+
run_async(
|
|
74
|
+
execute_command(
|
|
75
|
+
app_ctx,
|
|
76
|
+
vin_positional,
|
|
77
|
+
"actuate_trunk",
|
|
78
|
+
"trunk.frunk",
|
|
79
|
+
body={"which_trunk": "front"},
|
|
80
|
+
success_message="Frunk opened.",
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
# Sunroof command
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@trunk_group.command("sunroof")
|
|
91
|
+
@click.argument("vin_positional", required=False, default=None, metavar="VIN")
|
|
92
|
+
@click.option(
|
|
93
|
+
"--state",
|
|
94
|
+
type=click.Choice(["vent", "close", "stop"]),
|
|
95
|
+
required=True,
|
|
96
|
+
help="Sunroof action: vent, close, or stop",
|
|
97
|
+
)
|
|
98
|
+
@global_options
|
|
99
|
+
def sunroof_cmd(app_ctx: AppContext, vin_positional: str | None, state: str) -> None:
|
|
100
|
+
"""Control the panoramic sunroof (vent, close, or stop)."""
|
|
101
|
+
messages = {"vent": "Sunroof vented.", "close": "Sunroof closed.", "stop": "Sunroof stopped."}
|
|
102
|
+
run_async(
|
|
103
|
+
execute_command(
|
|
104
|
+
app_ctx,
|
|
105
|
+
vin_positional,
|
|
106
|
+
"sun_roof_control",
|
|
107
|
+
"trunk.sunroof",
|
|
108
|
+
body={"state": state},
|
|
109
|
+
success_message=messages[state],
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
# Tonneau cover commands (Cybertruck)
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@trunk_group.command("tonneau-open")
|
|
120
|
+
@click.argument("vin_positional", required=False, default=None, metavar="VIN")
|
|
121
|
+
@global_options
|
|
122
|
+
def tonneau_open_cmd(app_ctx: AppContext, vin_positional: str | None) -> None:
|
|
123
|
+
"""Open the Cybertruck tonneau cover."""
|
|
124
|
+
run_async(
|
|
125
|
+
execute_command(
|
|
126
|
+
app_ctx,
|
|
127
|
+
vin_positional,
|
|
128
|
+
"open_tonneau",
|
|
129
|
+
"trunk.tonneau-open",
|
|
130
|
+
success_message="Tonneau cover opening.",
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@trunk_group.command("tonneau-close")
|
|
136
|
+
@click.argument("vin_positional", required=False, default=None, metavar="VIN")
|
|
137
|
+
@global_options
|
|
138
|
+
def tonneau_close_cmd(app_ctx: AppContext, vin_positional: str | None) -> None:
|
|
139
|
+
"""Close the Cybertruck tonneau cover."""
|
|
140
|
+
run_async(
|
|
141
|
+
execute_command(
|
|
142
|
+
app_ctx,
|
|
143
|
+
vin_positional,
|
|
144
|
+
"close_tonneau",
|
|
145
|
+
"trunk.tonneau-close",
|
|
146
|
+
success_message="Tonneau cover closing.",
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@trunk_group.command("tonneau-stop")
|
|
152
|
+
@click.argument("vin_positional", required=False, default=None, metavar="VIN")
|
|
153
|
+
@global_options
|
|
154
|
+
def tonneau_stop_cmd(app_ctx: AppContext, vin_positional: str | None) -> None:
|
|
155
|
+
"""Stop the Cybertruck tonneau cover movement."""
|
|
156
|
+
run_async(
|
|
157
|
+
execute_command(
|
|
158
|
+
app_ctx,
|
|
159
|
+
vin_positional,
|
|
160
|
+
"stop_tonneau",
|
|
161
|
+
"trunk.tonneau-stop",
|
|
162
|
+
success_message="Tonneau cover stopped.",
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# ---------------------------------------------------------------------------
|
|
168
|
+
# Window command
|
|
169
|
+
# ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@trunk_group.command("window")
|
|
173
|
+
@click.argument("vin_positional", required=False, default=None, metavar="VIN")
|
|
174
|
+
@click.option("--vent/--close", "vent", default=True, help="Vent or close windows")
|
|
175
|
+
@click.option("--lat", type=float, default=None, help="Vehicle latitude (for close)")
|
|
176
|
+
@click.option("--lon", type=float, default=None, help="Vehicle longitude (for close)")
|
|
177
|
+
@global_options
|
|
178
|
+
def window_cmd(
|
|
179
|
+
app_ctx: AppContext,
|
|
180
|
+
vin_positional: str | None,
|
|
181
|
+
vent: bool,
|
|
182
|
+
lat: float | None,
|
|
183
|
+
lon: float | None,
|
|
184
|
+
) -> None:
|
|
185
|
+
"""Vent or close all windows.
|
|
186
|
+
|
|
187
|
+
Closing windows requires vehicle coordinates. If --lat/--lon are not
|
|
188
|
+
provided, the vehicle's current location will be fetched automatically.
|
|
189
|
+
"""
|
|
190
|
+
run_async(_cmd_window(app_ctx, vin_positional, vent, lat, lon))
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
async def _cmd_window(
|
|
194
|
+
app_ctx: AppContext,
|
|
195
|
+
vin_positional: str | None,
|
|
196
|
+
vent: bool,
|
|
197
|
+
lat: float | None,
|
|
198
|
+
lon: float | None,
|
|
199
|
+
) -> None:
|
|
200
|
+
formatter = app_ctx.formatter
|
|
201
|
+
vin = require_vin(vin_positional, app_ctx.vin)
|
|
202
|
+
client, vehicle_api, cmd_api = get_command_api(app_ctx)
|
|
203
|
+
try:
|
|
204
|
+
|
|
205
|
+
async def _execute_window() -> CommandResponse:
|
|
206
|
+
if vent:
|
|
207
|
+
cmd_str = "vent"
|
|
208
|
+
use_lat = lat if lat is not None else 0.0
|
|
209
|
+
use_lon = lon if lon is not None else 0.0
|
|
210
|
+
else:
|
|
211
|
+
cmd_str = "close"
|
|
212
|
+
if lat is not None and lon is not None:
|
|
213
|
+
use_lat, use_lon = lat, lon
|
|
214
|
+
else:
|
|
215
|
+
vdata = await vehicle_api.get_vehicle_data(vin, endpoints=["drive_state"])
|
|
216
|
+
ds = vdata.drive_state
|
|
217
|
+
if ds and ds.latitude is not None and ds.longitude is not None:
|
|
218
|
+
use_lat, use_lon = ds.latitude, ds.longitude
|
|
219
|
+
else:
|
|
220
|
+
use_lat, use_lon = 0.0, 0.0
|
|
221
|
+
return await cmd_api.window_control(vin, command=cmd_str, lat=use_lat, lon=use_lon)
|
|
222
|
+
|
|
223
|
+
result = await auto_wake(
|
|
224
|
+
formatter,
|
|
225
|
+
vehicle_api,
|
|
226
|
+
vin,
|
|
227
|
+
_execute_window,
|
|
228
|
+
auto=app_ctx.auto_wake,
|
|
229
|
+
)
|
|
230
|
+
finally:
|
|
231
|
+
await client.close()
|
|
232
|
+
|
|
233
|
+
invalidate_cache_for_vin(app_ctx, vin)
|
|
234
|
+
|
|
235
|
+
if formatter.format == "json":
|
|
236
|
+
formatter.output(result, command="trunk.window")
|
|
237
|
+
else:
|
|
238
|
+
action = "vented" if vent else "closed"
|
|
239
|
+
msg = result.response.reason or f"Windows {action}."
|
|
240
|
+
formatter.rich.command_result(result.response.result, msg)
|