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/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)