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.
@@ -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()