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