gac-connect 0.1.0__tar.gz
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.
- gac_connect-0.1.0/.gitignore +20 -0
- gac_connect-0.1.0/LICENSE +21 -0
- gac_connect-0.1.0/PKG-INFO +115 -0
- gac_connect-0.1.0/README.md +86 -0
- gac_connect-0.1.0/pyproject.toml +69 -0
- gac_connect-0.1.0/src/gac_connect/__init__.py +33 -0
- gac_connect-0.1.0/src/gac_connect/_material.pem +61 -0
- gac_connect-0.1.0/src/gac_connect/auth.py +57 -0
- gac_connect-0.1.0/src/gac_connect/cli.py +168 -0
- gac_connect-0.1.0/src/gac_connect/client.py +345 -0
- gac_connect-0.1.0/src/gac_connect/commands.py +86 -0
- gac_connect-0.1.0/src/gac_connect/const.py +57 -0
- gac_connect-0.1.0/src/gac_connect/crypto.py +111 -0
- gac_connect-0.1.0/src/gac_connect/errors.py +54 -0
- gac_connect-0.1.0/src/gac_connect/keys.py +81 -0
- gac_connect-0.1.0/src/gac_connect/models.py +180 -0
- gac_connect-0.1.0/src/gac_connect/py.typed +0 -0
- gac_connect-0.1.0/src/gac_connect/session.py +83 -0
- gac_connect-0.1.0/src/gac_connect/signing.py +55 -0
- gac_connect-0.1.0/src/gac_connect/vehicle.py +91 -0
- gac_connect-0.1.0/tests/test_offline.py +94 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Local-only maintainer tooling (kept out of the published repo)
|
|
2
|
+
scripts/build_material.py
|
|
3
|
+
material.txt
|
|
4
|
+
keys/
|
|
5
|
+
|
|
6
|
+
# Python
|
|
7
|
+
.venv/
|
|
8
|
+
__pycache__/
|
|
9
|
+
*.pyc
|
|
10
|
+
*.egg-info/
|
|
11
|
+
build/
|
|
12
|
+
dist/
|
|
13
|
+
.pytest_cache/
|
|
14
|
+
.ruff_cache/
|
|
15
|
+
.mypy_cache/
|
|
16
|
+
|
|
17
|
+
# Local-only maintainer/dev scripts (reference the research tree or hit live API)
|
|
18
|
+
tests/test_compat.py
|
|
19
|
+
tests/live_smoke.py
|
|
20
|
+
tests/close_windows.py
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 GAC Connect contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: gac-connect
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Unofficial async client for GAC / Aion international connected-car accounts
|
|
5
|
+
Project-URL: Homepage, https://github.com/wattferry/gac-connect
|
|
6
|
+
Project-URL: Issues, https://github.com/wattferry/gac-connect/issues
|
|
7
|
+
Author: GAC Connect contributors
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: aion,car,ev,gac,home-assistant
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Framework :: AsyncIO
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Topic :: Home Automation
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Requires-Dist: aiohttp>=3.9
|
|
19
|
+
Requires-Dist: cryptography>=44
|
|
20
|
+
Provides-Extra: cli
|
|
21
|
+
Requires-Dist: rich>=13; extra == 'cli'
|
|
22
|
+
Requires-Dist: typer>=0.12; extra == 'cli'
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: mypy>=1.11; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
27
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# GAC Connect
|
|
31
|
+
|
|
32
|
+
An unofficial async Python client for **GAC / Aion international** connected-car
|
|
33
|
+
accounts — read your vehicle's status and control charging from your own code.
|
|
34
|
+
|
|
35
|
+
> Not affiliated with, endorsed by, or supported by GAC. Use it with a vehicle
|
|
36
|
+
> you own, on your own account. Names and trademarks belong to their owners.
|
|
37
|
+
|
|
38
|
+
Works today for **Australia and New Zealand**. Other regions are listed but
|
|
39
|
+
unverified — reports welcome.
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install gac-connect # library
|
|
45
|
+
pip install "gac-connect[cli]" # + the `gac` command
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Library
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
import aiohttp
|
|
52
|
+
from gac_connect import GacClient # noqa
|
|
53
|
+
|
|
54
|
+
async def main():
|
|
55
|
+
async with aiohttp.ClientSession() as http:
|
|
56
|
+
client = GacClient("AU", http)
|
|
57
|
+
|
|
58
|
+
# First run: sign in. You solve a slide puzzle and enter an SMS code.
|
|
59
|
+
captcha = await client.start_captcha()
|
|
60
|
+
# ... show captcha.background / captcha.piece, get the slide offset x ...
|
|
61
|
+
await client.request_sms("04xxxxxxxx", x)
|
|
62
|
+
await client.login_sms("04xxxxxxxx", "123456")
|
|
63
|
+
|
|
64
|
+
vehicles = await client.list_vehicles()
|
|
65
|
+
status = await client.get_status(vehicles[0].vin)
|
|
66
|
+
print(status.soc, status.range_km, status.odometer_km)
|
|
67
|
+
|
|
68
|
+
await client.charge_pause(vehicles[0].vin)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Pass a `TokenStore` to persist the session (the refresh token is single-use and
|
|
72
|
+
rotates on every refresh, so persistence matters):
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from gac_connect.session import FileStore
|
|
76
|
+
client = GacClient("AU", http, FileStore("~/.config/gac-connect/session.json"))
|
|
77
|
+
await client.load()
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## CLI
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
gac login --mobile 04xxxxxxxx # opens a browser puzzle, then asks for the SMS code
|
|
84
|
+
gac vehicles
|
|
85
|
+
gac status <VIN>
|
|
86
|
+
gac charge <VIN> pause # or: now
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## What you can read
|
|
90
|
+
|
|
91
|
+
State of charge, range, odometer, charging state and mode, plugged-in, cabin
|
|
92
|
+
temperature, 12 V battery, GPS, doors / windows / boot / lock state, and per-tyre
|
|
93
|
+
pressure (in kPa) and temperature.
|
|
94
|
+
|
|
95
|
+
## Charging control
|
|
96
|
+
|
|
97
|
+
`charge_now`, `charge_pause`, and `set_charge_window` gate charging through the
|
|
98
|
+
car's schedule. Pausing stops charging within seconds; resuming takes the car a
|
|
99
|
+
few minutes to act on — so pause only for sustained periods.
|
|
100
|
+
|
|
101
|
+
## Notes and limits
|
|
102
|
+
|
|
103
|
+
- Sign-in needs a human: a slide puzzle and an SMS code. There is no headless login.
|
|
104
|
+
- Remote lock/unlock, engine, and charger-release need a remote-control PIN that
|
|
105
|
+
this client does not yet support; those commands are refused.
|
|
106
|
+
- Using the official app and this client on the same account at the same time can
|
|
107
|
+
occasionally sign one of them out.
|
|
108
|
+
|
|
109
|
+
## Home Assistant
|
|
110
|
+
|
|
111
|
+
A Home Assistant integration built on this library is available separately.
|
|
112
|
+
|
|
113
|
+
## License
|
|
114
|
+
|
|
115
|
+
MIT.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# GAC Connect
|
|
2
|
+
|
|
3
|
+
An unofficial async Python client for **GAC / Aion international** connected-car
|
|
4
|
+
accounts — read your vehicle's status and control charging from your own code.
|
|
5
|
+
|
|
6
|
+
> Not affiliated with, endorsed by, or supported by GAC. Use it with a vehicle
|
|
7
|
+
> you own, on your own account. Names and trademarks belong to their owners.
|
|
8
|
+
|
|
9
|
+
Works today for **Australia and New Zealand**. Other regions are listed but
|
|
10
|
+
unverified — reports welcome.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install gac-connect # library
|
|
16
|
+
pip install "gac-connect[cli]" # + the `gac` command
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Library
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
import aiohttp
|
|
23
|
+
from gac_connect import GacClient # noqa
|
|
24
|
+
|
|
25
|
+
async def main():
|
|
26
|
+
async with aiohttp.ClientSession() as http:
|
|
27
|
+
client = GacClient("AU", http)
|
|
28
|
+
|
|
29
|
+
# First run: sign in. You solve a slide puzzle and enter an SMS code.
|
|
30
|
+
captcha = await client.start_captcha()
|
|
31
|
+
# ... show captcha.background / captcha.piece, get the slide offset x ...
|
|
32
|
+
await client.request_sms("04xxxxxxxx", x)
|
|
33
|
+
await client.login_sms("04xxxxxxxx", "123456")
|
|
34
|
+
|
|
35
|
+
vehicles = await client.list_vehicles()
|
|
36
|
+
status = await client.get_status(vehicles[0].vin)
|
|
37
|
+
print(status.soc, status.range_km, status.odometer_km)
|
|
38
|
+
|
|
39
|
+
await client.charge_pause(vehicles[0].vin)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Pass a `TokenStore` to persist the session (the refresh token is single-use and
|
|
43
|
+
rotates on every refresh, so persistence matters):
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from gac_connect.session import FileStore
|
|
47
|
+
client = GacClient("AU", http, FileStore("~/.config/gac-connect/session.json"))
|
|
48
|
+
await client.load()
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## CLI
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
gac login --mobile 04xxxxxxxx # opens a browser puzzle, then asks for the SMS code
|
|
55
|
+
gac vehicles
|
|
56
|
+
gac status <VIN>
|
|
57
|
+
gac charge <VIN> pause # or: now
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## What you can read
|
|
61
|
+
|
|
62
|
+
State of charge, range, odometer, charging state and mode, plugged-in, cabin
|
|
63
|
+
temperature, 12 V battery, GPS, doors / windows / boot / lock state, and per-tyre
|
|
64
|
+
pressure (in kPa) and temperature.
|
|
65
|
+
|
|
66
|
+
## Charging control
|
|
67
|
+
|
|
68
|
+
`charge_now`, `charge_pause`, and `set_charge_window` gate charging through the
|
|
69
|
+
car's schedule. Pausing stops charging within seconds; resuming takes the car a
|
|
70
|
+
few minutes to act on — so pause only for sustained periods.
|
|
71
|
+
|
|
72
|
+
## Notes and limits
|
|
73
|
+
|
|
74
|
+
- Sign-in needs a human: a slide puzzle and an SMS code. There is no headless login.
|
|
75
|
+
- Remote lock/unlock, engine, and charger-release need a remote-control PIN that
|
|
76
|
+
this client does not yet support; those commands are refused.
|
|
77
|
+
- Using the official app and this client on the same account at the same time can
|
|
78
|
+
occasionally sign one of them out.
|
|
79
|
+
|
|
80
|
+
## Home Assistant
|
|
81
|
+
|
|
82
|
+
A Home Assistant integration built on this library is available separately.
|
|
83
|
+
|
|
84
|
+
## License
|
|
85
|
+
|
|
86
|
+
MIT.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "gac-connect"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Unofficial async client for GAC / Aion international connected-car accounts"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
keywords = ["gac", "aion", "ev", "car", "home-assistant"]
|
|
14
|
+
authors = [{ name = "GAC Connect contributors" }]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Framework :: AsyncIO",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
21
|
+
"Topic :: Home Automation",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"aiohttp>=3.9",
|
|
25
|
+
"cryptography>=44",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
cli = ["typer>=0.12", "rich>=13"]
|
|
30
|
+
dev = ["pytest>=8", "pytest-asyncio>=0.23", "ruff>=0.6", "mypy>=1.11"]
|
|
31
|
+
|
|
32
|
+
[project.scripts]
|
|
33
|
+
gac = "gac_connect.cli:app"
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Homepage = "https://github.com/wattferry/gac-connect"
|
|
37
|
+
Issues = "https://github.com/wattferry/gac-connect/issues"
|
|
38
|
+
|
|
39
|
+
[tool.hatch.build.targets.wheel]
|
|
40
|
+
packages = ["src/gac_connect"]
|
|
41
|
+
|
|
42
|
+
[tool.hatch.build.targets.wheel.force-include]
|
|
43
|
+
"src/gac_connect/_material.pem" = "gac_connect/_material.pem"
|
|
44
|
+
|
|
45
|
+
[tool.pytest.ini_options]
|
|
46
|
+
asyncio_mode = "auto"
|
|
47
|
+
testpaths = ["tests"]
|
|
48
|
+
|
|
49
|
+
[tool.ruff]
|
|
50
|
+
line-length = 120
|
|
51
|
+
target-version = "py311"
|
|
52
|
+
|
|
53
|
+
[tool.ruff.lint]
|
|
54
|
+
select = ["E", "F", "I", "UP", "B", "S"]
|
|
55
|
+
# S101 asserts in tests; S311 request-id is not security-sensitive;
|
|
56
|
+
# S105 flags the ERR_TOKEN_INVALID constant name (a code, not a secret).
|
|
57
|
+
ignore = ["S101", "S311", "S105"]
|
|
58
|
+
|
|
59
|
+
[tool.ruff.lint.per-file-ignores]
|
|
60
|
+
# The CLI's tiny embedded HTTP handler / puzzle page uses compact lines.
|
|
61
|
+
"src/gac_connect/cli.py" = ["E701", "E702", "E501"]
|
|
62
|
+
# The command catalog reads better as aligned single-line entries.
|
|
63
|
+
"src/gac_connect/commands.py" = ["E501"]
|
|
64
|
+
# Tests set sys.path before importing and keep fixtures inline.
|
|
65
|
+
"tests/*" = ["S105", "S106", "E402", "E501", "E701", "E702", "I001"]
|
|
66
|
+
|
|
67
|
+
[tool.mypy]
|
|
68
|
+
python_version = "3.11"
|
|
69
|
+
strict = true
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""GAC Connect — an unofficial client for GAC / Aion international connected-car accounts.
|
|
2
|
+
|
|
3
|
+
Not affiliated with, endorsed by, or supported by GAC. For use with a vehicle you
|
|
4
|
+
own, on your own account.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from .errors import (
|
|
9
|
+
AuthError,
|
|
10
|
+
AuthExpiredError,
|
|
11
|
+
CaptchaError,
|
|
12
|
+
CommandError,
|
|
13
|
+
GacError,
|
|
14
|
+
LoginError,
|
|
15
|
+
PinRequiredError,
|
|
16
|
+
RateLimitedError,
|
|
17
|
+
RegionError,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__version__ = "0.1.0"
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"AuthError",
|
|
24
|
+
"AuthExpiredError",
|
|
25
|
+
"CaptchaError",
|
|
26
|
+
"CommandError",
|
|
27
|
+
"GacError",
|
|
28
|
+
"LoginError",
|
|
29
|
+
"PinRequiredError",
|
|
30
|
+
"RateLimitedError",
|
|
31
|
+
"RegionError",
|
|
32
|
+
"__version__",
|
|
33
|
+
]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# @iov
|
|
2
|
+
-----BEGIN PRIVATE KEY-----
|
|
3
|
+
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCTY26LsPEAdCCc
|
|
4
|
+
0ZyJT+zNNXI9W4OJlWwY3uQRQkGpMFHfGOHBY0PsNMdEjgJ7nUjvdAi88uwR8Tqv
|
|
5
|
+
Z6Ifib5fLS7m6ow/zzgX5JWLppfll9YeTg2Lw6ad3gBudsi4MhFJcfQBK0pIgXNm
|
|
6
|
+
vc6C1u/tz/4ZBmSM4qTjyYg7qPJ9BlmoGgkKi51s70v4R4sX9cm/w6TF8eROhA2f
|
|
7
|
+
HIDKAAAukyTwSZOabS6jk6cZx3MFjCXF8MG3T5qSq6QsN62W6tmxdS1IIvzzMUqJ
|
|
8
|
+
frtpOZq4nILM4c1KJGooTp7Aufe2pGGXjBNOgVjtLelmxS3GL4nldOf35t7s3Q+Q
|
|
9
|
+
BqA1+ItTAgMBAAECggEAekiW4SNO8RdoL3PhsjMzvxZG0A1rVu04IHJdOsyD+cFz
|
|
10
|
+
S+wy8eZP5lvrsD86ZCEc8D6POWU647AHBpyu8AeygPjV3xRehFqCqaAnSxRPFlgh
|
|
11
|
+
eEc+CeWsCXK1XPEEm34+xXbcAVRPb1Sn0GhOPFPkAKT+pvic2B76u1rrwsPrXDpr
|
|
12
|
+
IF901wnxSo3FXuEtVIvyioQZOJaTDTDOBKwKiaVp26RXyg+AMRoo6rSlXvAA607n
|
|
13
|
+
BNoq3SVO8xGvnwUurQ5uu44LSaCG++u6CWop5pWEw8DURgVCoVrCnD8U3Cr4/Yb4
|
|
14
|
+
/wP4Rycd4WUO7nF8UsB8TVaqXIXGTUfhGMMUFbx0IQKBgQD2NLp7jDn3D6nwnUH9
|
|
15
|
+
CBDAIeiCdMNKzMWzyfflZ77g/5Wsmod4LgT9rBmJru8XkJbZ+pA/rOuyOei6E9kn
|
|
16
|
+
kjKri7z+TvKnPrEaW6IyzU0OMy0LqTK12VUykm7rpLvqGeMbXfyiijFG3DjgIseB
|
|
17
|
+
MAmbIjm9OlImaRyr3pjU7mJUKwKBgQCZQGGlllrgUbF+XLbL2lkICru2lRfVMpdS
|
|
18
|
+
/7RIuEqAs+2Rk/5hI37FedjmD6RjukNC3nVKf01C02onScXIoMK1aXwTVCttEJHT
|
|
19
|
+
N2Yxd6oq1LZ9/YzeqsdiCeSg6Ub1NmGrI5ZuQb/r6Lxi65H6CykI+nP1NoEEnxE3
|
|
20
|
+
hLNnjrvJeQKBgGhoU+ywuqQa8GYpSiKTmQbhWkh4W5kM3Yowy/9sz3V67W4U2f4N
|
|
21
|
+
BfDNWscYeCv2FQK7TxCxtAIQrLC8Wgp95R76U1tE/4INA+25jj8c8OBks2WOiwKy
|
|
22
|
+
935Kut8TLN1JvRWN9eS31c3G9heWqCQQywIOsY2dfdjsGkyISSeDMA+hAoGAIw4O
|
|
23
|
+
CAGHkg49yojMq36rpOJHo/EgvXzQDoU9KPE24Y/GxOkUZAXphqRjj+R+Fu3RWudl
|
|
24
|
+
7v+FncacXpZKbqUPRM33OrijIlSK9sSSVLhprVhw0gsAgSKRbwSSTphtycOBvdLr
|
|
25
|
+
IxdZHk5jYUE/Z/HLliksAyhPhM4Z8xggrYyBugkCgYB8gYMElqid+B7BjH7xf2pa
|
|
26
|
+
mLBe2D0xPM9P0Mh/bsPCrXQ/GeRUudEen0praQJ+41rL1hbnmVjPfPxD+iTm4WEd
|
|
27
|
+
txhxtFdXNhluVAhQm0gGc24pkSunEniypLnuXWWw6fIUc762szABQePhqUVF9Kfd
|
|
28
|
+
db//JziuiJ7sQKk63daMvg==
|
|
29
|
+
-----END PRIVATE KEY-----
|
|
30
|
+
# @main
|
|
31
|
+
-----BEGIN PRIVATE KEY-----
|
|
32
|
+
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCNDK5nyzEq3mFZ
|
|
33
|
+
XU2gzZZjID5NIx20pFcwo/UkV+z+wLfN73p0XyXtcBvW4NZkR9+8GpqWLWP1Rl6O
|
|
34
|
+
Qclnuj6N7Kh8kt8nhwoIGRoUPcIOy4ncTI2qFI9vdkwLxizOHKb2AG6ZMHT/Vwm8
|
|
35
|
+
JJKZ0js533noqhi/eA8QLcrZ4w86sg6lAhVzoVsl8HDShnBDJc74ekilcZ6cAb5h
|
|
36
|
+
Jd2vSN7wZ5hHoPwxb11OimjpmgJHdMxn7kF1YQ0mq4fyfpggOVnuOvXcmxSa4iYp
|
|
37
|
+
MLbXDmMQD27VUkQIkkbkMjPjd0yvzYIT27Z3fMJP9IOAbUEH+hcI26IcHLLPnd+M
|
|
38
|
+
9Q98scAFAgMBAAECggEAKZ+waBjibZAqkkCig+2nj6EYQZM3VPOad1QrJ4p6+cLN
|
|
39
|
+
ewxMysK5YpRO4t5+5iD/uXbwX5kNgng9tyVa1i10AFXycrC1Aekr4K/nrkl7Vvph
|
|
40
|
+
uI0u0R44JeB3GJJm7nwuGTKPcM2H+UIqMUdpq9MKf02foDVyEC7R4yhgUGRdPiiQ
|
|
41
|
+
3UWc7oWDo9ZDant5j4wIV6Rnuo4vwAFAYjwlZiCddWPrEqcGep3JUvLDRchA30iJ
|
|
42
|
+
WfF67L2h/RHLZqpYPmnDGN6gPWRlpTjNX6BOG5TtA4E6H3gZhxVxT95iRiBp+VIy
|
|
43
|
+
WyKy1YeFNzl/RLxdPExckvJiPF3xFV9L7GNiTj87kQKBgQD77f5TapAsf1pCW2/3
|
|
44
|
+
UqU46EtoKrD3N0VBrEpfcvbLj7hVHAUEa2hoCCjgRIt2e1e5s5K5lR2qJ67n5/IQ
|
|
45
|
+
P0m1sc1EPGx+Oj4CGDVoWqCb9Ix53Gpj+++wtE14I6XLSzhfKUBOqPxmTWuYpzvt
|
|
46
|
+
KfvYlG3bz0e2mhkHtOuTCINcbwKBgQCPVBOPXPTGHW2UskwpDhQm00irpuKdkbpq
|
|
47
|
+
w59nHtrzKMEL6Y5l/O4S3NsfzU2SNccUDF8RFf9QN4mQhYW3C20bR5PaqNtd951G
|
|
48
|
+
vbnyW/wlItdwb6MVYMgsijm9A2V94Bs0ADIRPB+/TXjk8ROmqvvZi+XzAQ/lc2jD
|
|
49
|
+
kbt1jJHMywKBgQCp2NJWOsnothOMGUlrXPS3YSU1nVKh2Ul/9UBPCZsJsmN8Pd/E
|
|
50
|
+
EwXY3Wdxi8TapF2IfWC2baoKRabOArpX6dMwBCd3kaUzlOCpWNjo+fC+NClhHxE7
|
|
51
|
+
R2dZorV4AdCNNrFStuT8I0HHVvrreBQW2O9KHQfT4fNfUJT/dSI9hsfJUQKBgDFf
|
|
52
|
+
BcnZfFuOIYQaEtAFkH17REoUl3UtkKhcMFmGMGeYPz9KOYn6IUwQFc8TmkfmDGhk
|
|
53
|
+
ho7NJABO0S0eQVw3LGM8ObsVdVMeC2glzJqmgumL4ehSHDwt9WYaSx9Hvzsn6lb0
|
|
54
|
+
RkrXihwMpLtAK8O6nDxoy1H0qLQiGBFd7tPkEwkXAoGBAPujP62cNUjDYhJmFbvQ
|
|
55
|
+
ibFxEfElpwOHiHhOpdJmLxGGuE6sp0loDC6xCF4aJvYkremMhkfzgH1ZUIc08+Dp
|
|
56
|
+
Vjs7QdkdZ6tj0npXf+okNN4SGO+EorrP/x24K8i8PA5KYegUoEC8aNhtWobdXOS4
|
|
57
|
+
qMBu+DnauTVDf8877H7+4hHw
|
|
58
|
+
-----END PRIVATE KEY-----
|
|
59
|
+
# iov_hmac: international-pro
|
|
60
|
+
# main_hmac: rgw19oelccch
|
|
61
|
+
# captcha_key: BGxdEUOZkXka4HSj
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Block-puzzle captcha helpers.
|
|
2
|
+
|
|
3
|
+
The gateway's sign-in is gated by an anji-plus block puzzle. The get response
|
|
4
|
+
returns two images and a ``secretKey`` of the form ``<32B key>@DS@<16B iv>`` plus
|
|
5
|
+
a one-time ``token``. The user slides the piece to an x offset; from that we build
|
|
6
|
+
two proofs, both AES-256-CBC with the session key/iv from ``secretKey``:
|
|
7
|
+
|
|
8
|
+
* ``pointJson`` = enc('{"x":X,"y":5}') — sent to /check and as randomStr
|
|
9
|
+
* ``captchaTicket`` = enc('<token>---{"x":X,"y":5}') — sent to send-code / login
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import base64
|
|
14
|
+
import json
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
from . import crypto
|
|
18
|
+
from .const import PUZZLE_HEIGHT, PUZZLE_PIECE_WIDTH, PUZZLE_WIDTH
|
|
19
|
+
|
|
20
|
+
_SEP = b"@DS@"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Captcha:
|
|
25
|
+
"""A pending puzzle. ``background`` and ``piece`` are base64 PNGs."""
|
|
26
|
+
background: str
|
|
27
|
+
piece: str
|
|
28
|
+
secret_key: str
|
|
29
|
+
token: str
|
|
30
|
+
width: int = PUZZLE_WIDTH
|
|
31
|
+
height: int = PUZZLE_HEIGHT
|
|
32
|
+
piece_width: int = PUZZLE_PIECE_WIDTH
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def from_data(cls, data: dict) -> Captcha:
|
|
36
|
+
return cls(
|
|
37
|
+
background=data["originalImageBase64"],
|
|
38
|
+
piece=data["jigsawImageBase64"],
|
|
39
|
+
secret_key=data["secretKey"],
|
|
40
|
+
token=data["token"],
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def _key_iv(self) -> tuple[bytes, bytes]:
|
|
44
|
+
key, iv = self.secret_key.encode().split(_SEP, 1)
|
|
45
|
+
return key, iv
|
|
46
|
+
|
|
47
|
+
def _point(self, x: int) -> bytes:
|
|
48
|
+
return json.dumps({"x": int(x), "y": 5}, separators=(",", ":")).encode()
|
|
49
|
+
|
|
50
|
+
def point_json(self, x: int) -> str:
|
|
51
|
+
key, iv = self._key_iv()
|
|
52
|
+
return base64.b64encode(crypto.aes_cbc_encrypt(key, iv, self._point(x))).decode()
|
|
53
|
+
|
|
54
|
+
def ticket(self, x: int) -> str:
|
|
55
|
+
key, iv = self._key_iv()
|
|
56
|
+
payload = f"{self.token}---".encode() + self._point(x)
|
|
57
|
+
return base64.b64encode(crypto.aes_cbc_encrypt(key, iv, payload)).decode()
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Command-line interface: ``gac login | status | vehicles | charge | command``.
|
|
2
|
+
|
|
3
|
+
Installed with the ``[cli]`` extra. Stores its session in
|
|
4
|
+
``~/.config/gac-connect/session.json`` (0600). The block puzzle opens in a local
|
|
5
|
+
browser page during ``gac login``.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import json
|
|
11
|
+
import queue
|
|
12
|
+
import threading
|
|
13
|
+
import webbrowser
|
|
14
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
import typer
|
|
19
|
+
except ModuleNotFoundError as exc: # pragma: no cover
|
|
20
|
+
raise SystemExit("the CLI needs the extra: pip install 'gac-connect[cli]'") from exc
|
|
21
|
+
|
|
22
|
+
import aiohttp
|
|
23
|
+
|
|
24
|
+
from .auth import Captcha
|
|
25
|
+
from .client import GacClient
|
|
26
|
+
from .errors import CaptchaError, LoginError
|
|
27
|
+
from .session import FileStore
|
|
28
|
+
|
|
29
|
+
app = typer.Typer(add_completion=False, help="Unofficial GAC / Aion client.")
|
|
30
|
+
SESSION = Path.home() / ".config" / "gac-connect" / "session.json"
|
|
31
|
+
|
|
32
|
+
_PAGE = """<!doctype html><meta charset=utf-8><title>GAC verification</title>
|
|
33
|
+
<style>body{font-family:system-ui;background:#111;color:#eee;display:flex;flex-direction:column;
|
|
34
|
+
align-items:center;gap:14px;padding:28px}.st{position:relative;width:310px;height:155px;background:#000;
|
|
35
|
+
border-radius:8px;overflow:hidden}.st img{position:absolute;top:0;left:0}input{width:310px}
|
|
36
|
+
button{font-size:16px;padding:9px 22px;border:0;border-radius:9px;background:#2e7d32;color:#fff}</style>
|
|
37
|
+
<h3>Slide the piece into the gap</h3><div class=st><img id=b width=310 height=155><img id=p></div>
|
|
38
|
+
<input id=s type=range min=0 max=263 value=0><div id=x style=font-size:28px>0</div><button id=g>Verify</button>
|
|
39
|
+
<div id=m></div><script>const s=document.getElementById('s'),p=document.getElementById('p'),
|
|
40
|
+
x=document.getElementById('x'),b=document.getElementById('b'),g=document.getElementById('g'),m=document.getElementById('m');
|
|
41
|
+
let a=0,d=0;const u=()=>{p.style.left=s.value+'px';x.textContent=s.value};s.oninput=u;
|
|
42
|
+
document.onkeydown=e=>{if(e.key=='ArrowRight'){s.value=+s.value+1;u()}if(e.key=='ArrowLeft'){s.value=+s.value-1;u()}};
|
|
43
|
+
async function q(){if(d)return;const t=await(await fetch('/state')).json();if(t.a!=a){a=t.a;b.src='data:image/png;base64,'+t.b;
|
|
44
|
+
p.src='data:image/png;base64,'+t.p;s.value=0;u();g.disabled=false;if(a>1)m.textContent='New puzzle (try '+a+')'}
|
|
45
|
+
if(t.s=='ok'){d=1;g.disabled=true;m.textContent='Verified. Back to the terminal.';return}
|
|
46
|
+
if(t.s=='failed'){d=1;m.textContent=t.m;return}setTimeout(q,400)}
|
|
47
|
+
g.onclick=async()=>{g.disabled=true;await fetch('/submit',{method:'POST',body:JSON.stringify({x:+s.value})})};q();</script>"""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class _Puzzle:
|
|
51
|
+
def __init__(self) -> None:
|
|
52
|
+
self.state = {"s": "pending", "a": 0, "b": "", "p": "", "m": ""}
|
|
53
|
+
self.q: queue.Queue[int] = queue.Queue()
|
|
54
|
+
self.lock = threading.Lock()
|
|
55
|
+
o = self
|
|
56
|
+
|
|
57
|
+
class H(BaseHTTPRequestHandler):
|
|
58
|
+
def log_message(self, *a): pass
|
|
59
|
+
def do_GET(self):
|
|
60
|
+
if self.path == "/state":
|
|
61
|
+
with o.lock:
|
|
62
|
+
body = json.dumps(o.state).encode()
|
|
63
|
+
else:
|
|
64
|
+
body = _PAGE.encode()
|
|
65
|
+
self.send_response(200); self.end_headers(); self.wfile.write(body)
|
|
66
|
+
def do_POST(self):
|
|
67
|
+
n = int(self.headers.get("content-length") or 0)
|
|
68
|
+
o.q.put(int(json.loads(self.rfile.read(n) or b"{}").get("x", 0)))
|
|
69
|
+
self.send_response(200); self.end_headers(); self.wfile.write(b"{}")
|
|
70
|
+
|
|
71
|
+
self.httpd = ThreadingHTTPServer(("127.0.0.1", 0), H)
|
|
72
|
+
threading.Thread(target=self.httpd.serve_forever, daemon=True).start()
|
|
73
|
+
self.url = f"http://127.0.0.1:{self.httpd.server_address[1]}/"
|
|
74
|
+
|
|
75
|
+
def show(self, c: Captcha, a: int) -> None:
|
|
76
|
+
with self.lock:
|
|
77
|
+
self.state.update(s="pending", a=a, b=c.background, p=c.piece, m="")
|
|
78
|
+
|
|
79
|
+
def done(self, ok: bool, m: str = "") -> None:
|
|
80
|
+
with self.lock:
|
|
81
|
+
self.state.update(s="ok" if ok else "failed", m=m)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def _run(coro):
|
|
85
|
+
return await coro
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _client(http: aiohttp.ClientSession, region: str) -> GacClient:
|
|
89
|
+
return GacClient(region, http, FileStore(SESSION))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@app.command()
|
|
93
|
+
def login(mobile: str = typer.Option(..., prompt=True), region: str = "AU") -> None:
|
|
94
|
+
"""Sign in with your mobile number and an SMS code."""
|
|
95
|
+
async def go() -> None:
|
|
96
|
+
async with aiohttp.ClientSession() as http:
|
|
97
|
+
client = _client(http, region)
|
|
98
|
+
pz = _Puzzle()
|
|
99
|
+
opened = False
|
|
100
|
+
for attempt in range(1, 6):
|
|
101
|
+
captcha = await client.start_captcha()
|
|
102
|
+
pz.show(captcha, attempt)
|
|
103
|
+
if not opened:
|
|
104
|
+
opened = webbrowser.open(pz.url)
|
|
105
|
+
typer.echo(f"Solve the puzzle: {pz.url}")
|
|
106
|
+
x = await asyncio.to_thread(pz.q.get)
|
|
107
|
+
try:
|
|
108
|
+
await client.request_sms(mobile, x)
|
|
109
|
+
pz.done(True)
|
|
110
|
+
break
|
|
111
|
+
except (CaptchaError, LoginError) as exc:
|
|
112
|
+
typer.echo(f" {exc}; new puzzle")
|
|
113
|
+
else:
|
|
114
|
+
raise typer.Exit(1)
|
|
115
|
+
code = typer.prompt("SMS code")
|
|
116
|
+
await client.login_sms(mobile, code)
|
|
117
|
+
typer.echo("Signed in.")
|
|
118
|
+
asyncio.run(go())
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@app.command()
|
|
122
|
+
def vehicles(region: str = "AU") -> None:
|
|
123
|
+
"""List the vehicles on the account."""
|
|
124
|
+
async def go() -> None:
|
|
125
|
+
async with aiohttp.ClientSession() as http:
|
|
126
|
+
client = _client(http, region)
|
|
127
|
+
await client.load()
|
|
128
|
+
for v in await client.list_vehicles():
|
|
129
|
+
typer.echo(f"{v.vin} {v.model or ''} {v.plate or ''}")
|
|
130
|
+
asyncio.run(go())
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@app.command()
|
|
134
|
+
def status(vin: str, region: str = "AU") -> None:
|
|
135
|
+
"""Show live status for a VIN."""
|
|
136
|
+
async def go() -> None:
|
|
137
|
+
async with aiohttp.ClientSession() as http:
|
|
138
|
+
client = _client(http, region)
|
|
139
|
+
await client.load()
|
|
140
|
+
s = await client.get_status(vin)
|
|
141
|
+
typer.echo(json.dumps({
|
|
142
|
+
"soc": s.soc, "range_km": s.range_km, "odometer_km": s.odometer_km,
|
|
143
|
+
"charging": s.charging, "plugged_in": s.plugged_in, "locked": s.locked,
|
|
144
|
+
"cabin_temp_c": s.cabin_temp_c, "aux_voltage": s.aux_voltage,
|
|
145
|
+
"tyres_kpa": [t.pressure_kpa for t in s.tyres],
|
|
146
|
+
}, indent=2))
|
|
147
|
+
asyncio.run(go())
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@app.command()
|
|
151
|
+
def charge(vin: str, action: str, region: str = "AU") -> None:
|
|
152
|
+
"""Charge control: action = now | pause."""
|
|
153
|
+
async def go() -> None:
|
|
154
|
+
async with aiohttp.ClientSession() as http:
|
|
155
|
+
client = _client(http, region)
|
|
156
|
+
await client.load()
|
|
157
|
+
if action == "now":
|
|
158
|
+
await client.charge_now(vin)
|
|
159
|
+
elif action == "pause":
|
|
160
|
+
await client.charge_pause(vin)
|
|
161
|
+
else:
|
|
162
|
+
raise typer.BadParameter("action must be now or pause")
|
|
163
|
+
typer.echo(f"charge {action}: accepted")
|
|
164
|
+
asyncio.run(go())
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
if __name__ == "__main__":
|
|
168
|
+
app()
|