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/billing.py ADDED
@@ -0,0 +1,240 @@
1
+ """CLI commands for Supercharger billing (history, sessions, invoices)."""
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_DEFAULT, cached_api_call, get_billing_api
11
+ from tescmd.cli._options import global_options
12
+
13
+ if TYPE_CHECKING:
14
+ from tescmd.cli.main import AppContext
15
+
16
+ billing_group = click.Group("billing", help="Supercharger billing history and invoices")
17
+
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # billing history
21
+ # ---------------------------------------------------------------------------
22
+
23
+
24
+ @billing_group.command("history")
25
+ @click.option("--vin", "vin_filter", default=None, help="Filter by vehicle VIN")
26
+ @click.option("--start", "start_time", default=None, help="Start time (ISO-8601)")
27
+ @click.option("--end", "end_time", default=None, help="End time (ISO-8601)")
28
+ @click.option("--page", type=int, default=None, help="Page number (0-based)")
29
+ @click.option("--page-size", type=int, default=None, help="Results per page")
30
+ @global_options
31
+ def history_cmd(
32
+ app_ctx: AppContext,
33
+ vin_filter: str | None,
34
+ start_time: str | None,
35
+ end_time: str | None,
36
+ page: int | None,
37
+ page_size: int | None,
38
+ ) -> None:
39
+ """Show Supercharger charging history."""
40
+ run_async(_cmd_history(app_ctx, vin_filter, start_time, end_time, page, page_size))
41
+
42
+
43
+ async def _cmd_history(
44
+ app_ctx: AppContext,
45
+ vin_filter: str | None,
46
+ start_time: str | None,
47
+ end_time: str | None,
48
+ page: int | None,
49
+ page_size: int | None,
50
+ ) -> None:
51
+ formatter = app_ctx.formatter
52
+ client, api = get_billing_api(app_ctx)
53
+
54
+ # Build params dict for cache key differentiation
55
+ cache_params: dict[str, str] = {}
56
+ if vin_filter:
57
+ cache_params["vin"] = vin_filter
58
+ if start_time:
59
+ cache_params["start"] = start_time
60
+ if end_time:
61
+ cache_params["end"] = end_time
62
+ if page is not None:
63
+ cache_params["page"] = str(page)
64
+ if page_size is not None:
65
+ cache_params["page_size"] = str(page_size)
66
+
67
+ try:
68
+ data = await cached_api_call(
69
+ app_ctx,
70
+ scope="account",
71
+ identifier="global",
72
+ endpoint="billing.history",
73
+ fetch=lambda: api.charging_history(
74
+ vin=vin_filter,
75
+ start_time=start_time,
76
+ end_time=end_time,
77
+ page_no=page,
78
+ page_size=page_size,
79
+ ),
80
+ ttl=TTL_DEFAULT,
81
+ params=cache_params or None,
82
+ )
83
+ finally:
84
+ await client.close()
85
+
86
+ if formatter.format == "json":
87
+ formatter.output(data, command="billing.history")
88
+ else:
89
+ _display_charging_history(formatter, data)
90
+
91
+
92
+ def _display_charging_history(formatter: object, data: dict) -> None: # type: ignore[type-arg]
93
+ """Render charging history in Rich format."""
94
+ from tescmd.output.formatter import OutputFormatter
95
+
96
+ assert isinstance(formatter, OutputFormatter)
97
+ records = data.get("data", data.get("chargingHistoryDetailList", []))
98
+ if not records:
99
+ formatter.rich.info("[dim]No charging history records found.[/dim]")
100
+ return
101
+
102
+ from rich.table import Table
103
+
104
+ table = Table(title="Charging History", show_lines=False)
105
+ table.add_column("Date", style="cyan")
106
+ table.add_column("VIN")
107
+ table.add_column("Location")
108
+ table.add_column("Energy (kWh)", justify="right")
109
+ table.add_column("Cost", justify="right")
110
+
111
+ for rec in records:
112
+ date = str(rec.get("sessionStartDateTime", rec.get("chargeStartDateTime", "—")))
113
+ vin = str(rec.get("vin", "—"))
114
+ location = str(rec.get("siteLocationName", rec.get("chargeSessionTitle", "—")))
115
+ energy = str(rec.get("chargeSessionEnergyKwh", rec.get("energyAdded", "—")))
116
+ fees = rec.get("fees", [])
117
+ cost = "—"
118
+ if fees and isinstance(fees, list):
119
+ total = sum(float(f.get("totalDue", 0)) for f in fees if isinstance(f, dict))
120
+ currency = fees[0].get("currencyCode", "") if fees else ""
121
+ cost = f"{total:.2f} {currency}".strip()
122
+ elif "billingTotal" in rec:
123
+ cost = str(rec["billingTotal"])
124
+ table.add_row(date[:16], vin, location[:30], energy, cost)
125
+
126
+ formatter.rich._con.print(table)
127
+
128
+
129
+ # ---------------------------------------------------------------------------
130
+ # billing sessions (business accounts)
131
+ # ---------------------------------------------------------------------------
132
+
133
+
134
+ @billing_group.command("sessions")
135
+ @click.option("--vin", "vin_filter", default=None, help="Filter by vehicle VIN")
136
+ @click.option("--from", "date_from", default=None, help="Start date (ISO-8601)")
137
+ @click.option("--to", "date_to", default=None, help="End date (ISO-8601)")
138
+ @click.option("--limit", type=int, default=None, help="Max results")
139
+ @click.option("--offset", type=int, default=None, help="Pagination offset")
140
+ @global_options
141
+ def sessions_cmd(
142
+ app_ctx: AppContext,
143
+ vin_filter: str | None,
144
+ date_from: str | None,
145
+ date_to: str | None,
146
+ limit: int | None,
147
+ offset: int | None,
148
+ ) -> None:
149
+ """Show charging sessions (business accounts only)."""
150
+ run_async(_cmd_sessions(app_ctx, vin_filter, date_from, date_to, limit, offset))
151
+
152
+
153
+ async def _cmd_sessions(
154
+ app_ctx: AppContext,
155
+ vin_filter: str | None,
156
+ date_from: str | None,
157
+ date_to: str | None,
158
+ limit: int | None,
159
+ offset: int | None,
160
+ ) -> None:
161
+ formatter = app_ctx.formatter
162
+ client, api = get_billing_api(app_ctx)
163
+
164
+ cache_params: dict[str, str] = {}
165
+ if vin_filter:
166
+ cache_params["vin"] = vin_filter
167
+ if date_from:
168
+ cache_params["from"] = date_from
169
+ if date_to:
170
+ cache_params["to"] = date_to
171
+ if limit is not None:
172
+ cache_params["limit"] = str(limit)
173
+ if offset is not None:
174
+ cache_params["offset"] = str(offset)
175
+
176
+ try:
177
+ data = await cached_api_call(
178
+ app_ctx,
179
+ scope="account",
180
+ identifier="global",
181
+ endpoint="billing.sessions",
182
+ fetch=lambda: api.charging_sessions(
183
+ vin=vin_filter,
184
+ date_from=date_from,
185
+ date_to=date_to,
186
+ limit=limit,
187
+ offset=offset,
188
+ ),
189
+ ttl=TTL_DEFAULT,
190
+ params=cache_params or None,
191
+ )
192
+ finally:
193
+ await client.close()
194
+
195
+ if formatter.format == "json":
196
+ formatter.output(data, command="billing.sessions")
197
+ else:
198
+ sessions = data if isinstance(data, list) else data.get("data", [])
199
+ if not sessions:
200
+ formatter.rich.info("[dim]No charging sessions found.[/dim]")
201
+ else:
202
+ formatter.rich.info(f"Found {len(sessions)} charging session(s).")
203
+ for s in sessions:
204
+ formatter.rich.info(f" {s}")
205
+
206
+
207
+ # ---------------------------------------------------------------------------
208
+ # billing invoice
209
+ # ---------------------------------------------------------------------------
210
+
211
+
212
+ @billing_group.command("invoice")
213
+ @click.argument("invoice_id")
214
+ @click.option("--output", "-o", "output_path", default=None, help="Save PDF to file path")
215
+ @global_options
216
+ def invoice_cmd(
217
+ app_ctx: AppContext,
218
+ invoice_id: str,
219
+ output_path: str | None,
220
+ ) -> None:
221
+ """Download a charging invoice by ID."""
222
+ run_async(_cmd_invoice(app_ctx, invoice_id, output_path))
223
+
224
+
225
+ async def _cmd_invoice(
226
+ app_ctx: AppContext,
227
+ invoice_id: str,
228
+ output_path: str | None,
229
+ ) -> None:
230
+ formatter = app_ctx.formatter
231
+ client, api = get_billing_api(app_ctx)
232
+ try:
233
+ data = await api.charging_invoice(invoice_id)
234
+ finally:
235
+ await client.close()
236
+
237
+ if formatter.format == "json":
238
+ formatter.output(data, command="billing.invoice")
239
+ else:
240
+ formatter.rich._dict_table(f"Invoice {invoice_id}", data)
tescmd/cli/cache.py ADDED
@@ -0,0 +1,85 @@
1
+ """CLI commands for cache management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ import click
8
+
9
+ from tescmd.cli._client import get_cache
10
+ from tescmd.cli._options import global_options
11
+
12
+ if TYPE_CHECKING:
13
+ from tescmd.cli.main import AppContext
14
+
15
+ cache_group = click.Group("cache", help="Response cache management")
16
+
17
+
18
+ @cache_group.command("clear")
19
+ @click.option("--site", "site_id", default=None, help="Clear cache for an energy site ID")
20
+ @click.option(
21
+ "--scope",
22
+ "scope",
23
+ type=click.Choice(["account", "partner"]),
24
+ default=None,
25
+ help="Clear cache entries for a scope (account or partner)",
26
+ )
27
+ @global_options
28
+ def clear_cmd(app_ctx: AppContext, site_id: str | None, scope: str | None) -> None:
29
+ """Clear cached API responses.
30
+
31
+ \b
32
+ Filters (first matching rule wins):
33
+ --vin VIN Vehicle-specific entries (legacy + generic)
34
+ --site SITE_ID Energy site entries
35
+ --scope account|partner Scope-level entries
36
+ (none) Clear everything
37
+ """
38
+ formatter = app_ctx.formatter
39
+ cache = get_cache(app_ctx)
40
+ target_vin = app_ctx.vin
41
+ removed = 0
42
+ label = ""
43
+
44
+ if target_vin:
45
+ removed += cache.clear(target_vin)
46
+ removed += cache.clear_by_prefix(f"vin_{target_vin}_")
47
+ label = f" for VIN {target_vin}"
48
+ elif site_id:
49
+ removed = cache.clear_by_prefix(f"site_{site_id}_")
50
+ label = f" for site {site_id}"
51
+ elif scope:
52
+ removed = cache.clear_by_prefix(f"{scope}_")
53
+ label = f" for scope '{scope}'"
54
+ else:
55
+ removed = cache.clear()
56
+
57
+ if formatter.format == "json":
58
+ formatter.output(
59
+ {"cleared": removed, "vin": target_vin, "site": site_id, "scope": scope},
60
+ command="cache.clear",
61
+ )
62
+ else:
63
+ formatter.rich.info(f"Cleared {removed} cache entries{label}.")
64
+
65
+
66
+ @cache_group.command("status")
67
+ @global_options
68
+ def status_cmd(app_ctx: AppContext) -> None:
69
+ """Show cache statistics."""
70
+ formatter = app_ctx.formatter
71
+ cache = get_cache(app_ctx)
72
+ info = cache.status()
73
+
74
+ if formatter.format == "json":
75
+ formatter.output(info, command="cache.status")
76
+ else:
77
+ enabled_str = "[green]enabled[/green]" if info["enabled"] else "[red]disabled[/red]"
78
+ formatter.rich.info(f"Cache: {enabled_str}")
79
+ formatter.rich.info(f"Directory: {info['cache_dir']}")
80
+ formatter.rich.info(f"Default TTL: {info['default_ttl']}s")
81
+ formatter.rich.info(
82
+ f"Entries: {info['total']} ({info['fresh']} fresh, {info['stale']} stale)"
83
+ )
84
+ disk_kb = info["disk_bytes"] / 1024
85
+ formatter.rich.info(f"Disk usage: {disk_kb:.1f} KB")