elli-client 1.0.3__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.
- elli_client/__init__.py +8 -0
- elli_client/client.py +353 -0
- elli_client/config.py +32 -0
- elli_client/models.py +62 -0
- elli_client-1.0.3.dist-info/METADATA +115 -0
- elli_client-1.0.3.dist-info/RECORD +9 -0
- elli_client-1.0.3.dist-info/WHEEL +5 -0
- elli_client-1.0.3.dist-info/licenses/LICENSE +21 -0
- elli_client-1.0.3.dist-info/top_level.txt +1 -0
elli_client/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Elli Client - Python client for Elli Wallbox API"""
|
|
2
|
+
|
|
3
|
+
from .client import ElliAPIClient
|
|
4
|
+
from .models import ChargingSession, FirmwareInfo, Station, TokenResponse
|
|
5
|
+
|
|
6
|
+
__version__ = "1.0.3"
|
|
7
|
+
|
|
8
|
+
__all__ = ["ElliAPIClient", "ChargingSession", "Station", "TokenResponse", "FirmwareInfo"]
|
elli_client/client.py
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
"""Elli API Client with OAuth2 PKCE Authentication"""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import re
|
|
6
|
+
import secrets
|
|
7
|
+
from typing import Any, Dict, Optional
|
|
8
|
+
from urllib.parse import parse_qs, urljoin, urlparse
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from .config import settings
|
|
13
|
+
from .models import ChargingSession, Station, TokenResponse
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ElliAPIClient:
|
|
17
|
+
"""Client for Elli Charging API with OAuth2 PKCE authentication"""
|
|
18
|
+
|
|
19
|
+
# Default OAuth2 configuration (from official Elli iOS app)
|
|
20
|
+
DEFAULT_AUTH_BASE_URL = "https://login.elli.eco"
|
|
21
|
+
DEFAULT_API_BASE_URL = "https://api.elli.eco"
|
|
22
|
+
DEFAULT_CLIENT_ID = "vFGCyS5GUbctkPk1FfcNH6TrDtyfUCwX"
|
|
23
|
+
DEFAULT_REDIRECT_URI = "com.elli.ios.emsp://login.elli.eco/ios/com.elli.ios.emsp/callback"
|
|
24
|
+
DEFAULT_AUDIENCE = "https://api.elli.eco/"
|
|
25
|
+
DEFAULT_SCOPE = "offline_access openid profile"
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
auth_base_url: Optional[str] = None,
|
|
30
|
+
api_base_url: Optional[str] = None,
|
|
31
|
+
client_id: Optional[str] = None,
|
|
32
|
+
redirect_uri: Optional[str] = None,
|
|
33
|
+
audience: Optional[str] = None,
|
|
34
|
+
scope: Optional[str] = None,
|
|
35
|
+
):
|
|
36
|
+
"""
|
|
37
|
+
Initialize Elli API Client.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
auth_base_url: OAuth2 authorization server URL (default: from env or DEFAULT_AUTH_BASE_URL)
|
|
41
|
+
api_base_url: Elli API base URL (default: from env or DEFAULT_API_BASE_URL)
|
|
42
|
+
client_id: OAuth2 client ID (default: from env or DEFAULT_CLIENT_ID)
|
|
43
|
+
redirect_uri: OAuth2 redirect URI (default: from env or DEFAULT_REDIRECT_URI)
|
|
44
|
+
audience: OAuth2 audience (default: from env or DEFAULT_AUDIENCE)
|
|
45
|
+
scope: OAuth2 scope (default: from env or DEFAULT_SCOPE)
|
|
46
|
+
"""
|
|
47
|
+
self.client = httpx.Client(timeout=30.0)
|
|
48
|
+
self.access_token: Optional[str] = None
|
|
49
|
+
self.refresh_token: Optional[str] = None
|
|
50
|
+
|
|
51
|
+
# Load config: parameter > environment > default
|
|
52
|
+
self.auth_base_url = auth_base_url or settings.elli_auth_base_url or self.DEFAULT_AUTH_BASE_URL
|
|
53
|
+
self.api_base_url = api_base_url or settings.elli_api_base_url or self.DEFAULT_API_BASE_URL
|
|
54
|
+
self.client_id = client_id or settings.elli_client_id or self.DEFAULT_CLIENT_ID
|
|
55
|
+
self.redirect_uri = redirect_uri or settings.elli_redirect_uri or self.DEFAULT_REDIRECT_URI
|
|
56
|
+
self.audience = audience or settings.elli_audience or self.DEFAULT_AUDIENCE
|
|
57
|
+
self.scope = scope or settings.elli_scope or self.DEFAULT_SCOPE
|
|
58
|
+
|
|
59
|
+
def _generate_pkce_pair(self) -> tuple[str, str]:
|
|
60
|
+
"""Generate PKCE code verifier and code challenge"""
|
|
61
|
+
# Generate code verifier (43-128 characters)
|
|
62
|
+
code_verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).decode("utf-8").rstrip("=")
|
|
63
|
+
|
|
64
|
+
# Generate code challenge (SHA256 hash of verifier)
|
|
65
|
+
code_challenge = (
|
|
66
|
+
base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode("utf-8")).digest()).decode("utf-8").rstrip("=")
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
return code_verifier, code_challenge
|
|
70
|
+
|
|
71
|
+
def _generate_state(self) -> str:
|
|
72
|
+
"""Generate random state parameter for OAuth2"""
|
|
73
|
+
return base64.urlsafe_b64encode(secrets.token_bytes(32)).decode("utf-8").rstrip("=")
|
|
74
|
+
|
|
75
|
+
def login(self, email: str, password: str) -> TokenResponse:
|
|
76
|
+
"""
|
|
77
|
+
Login to Elli API using username/password with OAuth2 PKCE flow.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
email: Elli account email
|
|
81
|
+
password: Elli account password
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
TokenResponse with access_token, refresh_token, etc.
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
ValueError: If login fails or authorization code cannot be extracted
|
|
88
|
+
"""
|
|
89
|
+
# Generate PKCE parameters
|
|
90
|
+
code_verifier, code_challenge = self._generate_pkce_pair()
|
|
91
|
+
state = self._generate_state()
|
|
92
|
+
|
|
93
|
+
# Step 1: Initiate authorization with PKCE
|
|
94
|
+
authorize_params = {
|
|
95
|
+
"client_id": self.client_id,
|
|
96
|
+
"code_challenge": code_challenge,
|
|
97
|
+
"code_challenge_method": "S256",
|
|
98
|
+
"audience": self.audience,
|
|
99
|
+
"redirect_uri": self.redirect_uri,
|
|
100
|
+
"scope": self.scope,
|
|
101
|
+
"response_type": "code",
|
|
102
|
+
"state": state,
|
|
103
|
+
"prompt": "login",
|
|
104
|
+
"connection_scope": "openid profile",
|
|
105
|
+
"ui_locales": "de",
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
# Get login page to obtain state and cookies
|
|
109
|
+
auth_response = self.client.get(
|
|
110
|
+
f"{self.auth_base_url}/authorize", params=authorize_params, follow_redirects=True
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# Extract state from the redirected URL
|
|
114
|
+
auth_state = state
|
|
115
|
+
if "state=" in str(auth_response.url):
|
|
116
|
+
parsed = urlparse(str(auth_response.url))
|
|
117
|
+
query_params = parse_qs(parsed.query)
|
|
118
|
+
if "state" in query_params:
|
|
119
|
+
auth_state = query_params["state"][0]
|
|
120
|
+
|
|
121
|
+
# Step 2: Submit login credentials
|
|
122
|
+
login_data = {"username": email, "password": password, "action": "default"}
|
|
123
|
+
|
|
124
|
+
# The actual login endpoint with state
|
|
125
|
+
login_url = f"{self.auth_base_url}/u/login"
|
|
126
|
+
login_params = {"state": auth_state, "ui_locales": "de"}
|
|
127
|
+
|
|
128
|
+
login_response = self.client.post(
|
|
129
|
+
login_url,
|
|
130
|
+
params=login_params,
|
|
131
|
+
data=login_data,
|
|
132
|
+
headers={
|
|
133
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
134
|
+
"Origin": self.auth_base_url,
|
|
135
|
+
},
|
|
136
|
+
follow_redirects=False,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# Step 3: Follow redirect chain to get authorization code
|
|
140
|
+
max_redirects = 10
|
|
141
|
+
redirect_count = 0
|
|
142
|
+
current_response = login_response
|
|
143
|
+
auth_code = None
|
|
144
|
+
|
|
145
|
+
while redirect_count < max_redirects:
|
|
146
|
+
if current_response.status_code not in [302, 303, 307, 308]:
|
|
147
|
+
break
|
|
148
|
+
|
|
149
|
+
location = current_response.headers.get("Location", "")
|
|
150
|
+
if not location:
|
|
151
|
+
break
|
|
152
|
+
|
|
153
|
+
# Make location absolute if it's relative
|
|
154
|
+
if location.startswith("/"):
|
|
155
|
+
location = urljoin(self.auth_base_url, location)
|
|
156
|
+
|
|
157
|
+
# Check if we have the authorization code
|
|
158
|
+
if "code=" in location:
|
|
159
|
+
code_match = re.search(r"code=([^&]+)", location)
|
|
160
|
+
if code_match:
|
|
161
|
+
auth_code = code_match.group(1)
|
|
162
|
+
break
|
|
163
|
+
|
|
164
|
+
# Follow the redirect
|
|
165
|
+
current_response = self.client.get(
|
|
166
|
+
location, follow_redirects=False, headers={"Referer": self.auth_base_url}
|
|
167
|
+
)
|
|
168
|
+
redirect_count += 1
|
|
169
|
+
|
|
170
|
+
if not auth_code:
|
|
171
|
+
# Check final response
|
|
172
|
+
if current_response.status_code == 200:
|
|
173
|
+
# Look for code in the response body or URL
|
|
174
|
+
code_match = re.search(r'code=([^&\'"]+)', str(current_response.text))
|
|
175
|
+
if code_match:
|
|
176
|
+
auth_code = code_match.group(1)
|
|
177
|
+
|
|
178
|
+
if not auth_code:
|
|
179
|
+
raise ValueError(
|
|
180
|
+
f"Could not extract authorization code. Last status: {current_response.status_code}, "
|
|
181
|
+
f"Last URL: {current_response.url}"
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
# Step 4: Exchange authorization code for tokens
|
|
185
|
+
token_data = {
|
|
186
|
+
"code": auth_code,
|
|
187
|
+
"client_id": self.client_id,
|
|
188
|
+
"redirect_uri": self.redirect_uri,
|
|
189
|
+
"grant_type": "authorization_code",
|
|
190
|
+
"code_verifier": code_verifier,
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
token_response = self.client.post(
|
|
194
|
+
f"{self.auth_base_url}/oauth/token",
|
|
195
|
+
json=token_data,
|
|
196
|
+
headers={
|
|
197
|
+
"Content-Type": "application/json",
|
|
198
|
+
},
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
if token_response.status_code != 200:
|
|
202
|
+
raise ValueError(f"Token exchange failed: {token_response.status_code} - {token_response.text}")
|
|
203
|
+
|
|
204
|
+
token_data_response = token_response.json()
|
|
205
|
+
token = TokenResponse(**token_data_response)
|
|
206
|
+
|
|
207
|
+
# Store tokens
|
|
208
|
+
self.access_token = token.access_token
|
|
209
|
+
self.refresh_token = token.refresh_token
|
|
210
|
+
|
|
211
|
+
return token
|
|
212
|
+
|
|
213
|
+
def _get_headers(self) -> Dict[str, str]:
|
|
214
|
+
"""Get headers for API requests"""
|
|
215
|
+
if not self.access_token:
|
|
216
|
+
raise ValueError("Not authenticated. Call login() first.")
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
"Authorization": f"Bearer {self.access_token}",
|
|
220
|
+
"Content-Type": "application/json",
|
|
221
|
+
"Accept": "application/json",
|
|
222
|
+
"User-Agent": "Elli-Charging-Prod/10221",
|
|
223
|
+
"platform": "iOS",
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
def get_charging_sessions(self, include_momentary_speed: bool = True) -> list[ChargingSession]:
|
|
227
|
+
"""
|
|
228
|
+
Get all charging sessions.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
include_momentary_speed: Include momentary charging power in watts (default: True)
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
List of ChargingSession objects containing session data including:
|
|
235
|
+
- Session ID, station ID, status
|
|
236
|
+
- Start/end times
|
|
237
|
+
- Energy consumption in Wh
|
|
238
|
+
- Momentary power in W (if include_momentary_speed=True)
|
|
239
|
+
- RFID card information
|
|
240
|
+
|
|
241
|
+
Raises:
|
|
242
|
+
ValueError: If not authenticated or API request fails
|
|
243
|
+
"""
|
|
244
|
+
params = {}
|
|
245
|
+
if include_momentary_speed:
|
|
246
|
+
params["include_momentary_charging_speed_watts"] = "true"
|
|
247
|
+
|
|
248
|
+
response = self.client.get(
|
|
249
|
+
f"{self.api_base_url}/chargeathome/v1/charging-sessions", headers=self._get_headers(), params=params
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
if response.status_code != 200:
|
|
253
|
+
raise ValueError(f"Failed to get charging sessions: {response.status_code} - {response.text}")
|
|
254
|
+
|
|
255
|
+
data = response.json()
|
|
256
|
+
sessions = []
|
|
257
|
+
for session_data in data.get("charging_sessions", []):
|
|
258
|
+
sessions.append(ChargingSession(**session_data))
|
|
259
|
+
|
|
260
|
+
return sessions
|
|
261
|
+
|
|
262
|
+
def get_stations(self) -> list[Station]:
|
|
263
|
+
"""
|
|
264
|
+
Get all charging stations associated with the account.
|
|
265
|
+
|
|
266
|
+
Returns:
|
|
267
|
+
List of Station objects containing:
|
|
268
|
+
- Station ID, name, serial number
|
|
269
|
+
- Model information
|
|
270
|
+
- Firmware version
|
|
271
|
+
- Status (e.g., IDLE, CONNECTED, CHARGING)
|
|
272
|
+
|
|
273
|
+
Raises:
|
|
274
|
+
ValueError: If not authenticated or API request fails
|
|
275
|
+
"""
|
|
276
|
+
response = self.client.get(f"{self.api_base_url}/chargeathome/v1/stations", headers=self._get_headers())
|
|
277
|
+
|
|
278
|
+
if response.status_code != 200:
|
|
279
|
+
raise ValueError(f"Failed to get stations: {response.status_code} - {response.text}")
|
|
280
|
+
|
|
281
|
+
data = response.json()
|
|
282
|
+
stations = []
|
|
283
|
+
for station_data in data.get("stations", []):
|
|
284
|
+
stations.append(Station(**station_data))
|
|
285
|
+
|
|
286
|
+
return stations
|
|
287
|
+
|
|
288
|
+
def get_firmware_info(self) -> list[Station]:
|
|
289
|
+
"""
|
|
290
|
+
Get firmware information for all stations.
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
List of Station objects with firmware details including:
|
|
294
|
+
- Current firmware version
|
|
295
|
+
- Available updates (if any)
|
|
296
|
+
|
|
297
|
+
Raises:
|
|
298
|
+
ValueError: If not authenticated or API request fails
|
|
299
|
+
"""
|
|
300
|
+
response = self.client.get(f"{self.api_base_url}/chargeathome/v1/firmware/updates", headers=self._get_headers())
|
|
301
|
+
|
|
302
|
+
if response.status_code != 200:
|
|
303
|
+
raise ValueError(f"Failed to get firmware info: {response.status_code} - {response.text}")
|
|
304
|
+
|
|
305
|
+
data = response.json()
|
|
306
|
+
stations = []
|
|
307
|
+
for station_data in data.get("stations", []):
|
|
308
|
+
stations.append(Station(**station_data))
|
|
309
|
+
|
|
310
|
+
return stations
|
|
311
|
+
|
|
312
|
+
def get_accumulated_charging(self, station_id: str) -> Dict[str, Any]:
|
|
313
|
+
"""
|
|
314
|
+
Get accumulated charging statistics for a specific station.
|
|
315
|
+
|
|
316
|
+
Args:
|
|
317
|
+
station_id: The ID of the charging station
|
|
318
|
+
|
|
319
|
+
Returns:
|
|
320
|
+
Dictionary containing accumulated statistics:
|
|
321
|
+
- Total energy charged (kWh)
|
|
322
|
+
- Total number of sessions
|
|
323
|
+
- Time-based statistics
|
|
324
|
+
|
|
325
|
+
Raises:
|
|
326
|
+
ValueError: If not authenticated or API request fails
|
|
327
|
+
"""
|
|
328
|
+
response = self.client.get(
|
|
329
|
+
f"{self.api_base_url}/chargeathome/v1/charging-sessions/accumulated",
|
|
330
|
+
headers=self._get_headers(),
|
|
331
|
+
params={"station_id": station_id},
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
if response.status_code != 200:
|
|
335
|
+
raise ValueError(f"Failed to get accumulated charging: {response.status_code} - {response.text}")
|
|
336
|
+
|
|
337
|
+
return response.json()
|
|
338
|
+
|
|
339
|
+
def close(self):
|
|
340
|
+
"""
|
|
341
|
+
Close the HTTP client and cleanup resources.
|
|
342
|
+
|
|
343
|
+
Should be called when done using the client, or use context manager (with statement).
|
|
344
|
+
"""
|
|
345
|
+
self.client.close()
|
|
346
|
+
|
|
347
|
+
def __enter__(self):
|
|
348
|
+
"""Context manager entry"""
|
|
349
|
+
return self
|
|
350
|
+
|
|
351
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
352
|
+
"""Context manager exit - ensures cleanup"""
|
|
353
|
+
self.close()
|
elli_client/config.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Configuration management for Elli API"""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from pydantic_settings import BaseSettings
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Settings(BaseSettings):
|
|
9
|
+
"""Application settings loaded from environment variables"""
|
|
10
|
+
|
|
11
|
+
# User Credentials
|
|
12
|
+
elli_email: Optional[str] = None
|
|
13
|
+
elli_password: Optional[str] = None
|
|
14
|
+
elli_station_id: Optional[str] = None
|
|
15
|
+
|
|
16
|
+
# OAuth2 Configuration (optional overrides)
|
|
17
|
+
# If not set in .env, defaults from ElliAPIClient will be used
|
|
18
|
+
elli_auth_base_url: Optional[str] = None
|
|
19
|
+
elli_api_base_url: Optional[str] = None
|
|
20
|
+
elli_client_id: Optional[str] = None
|
|
21
|
+
elli_redirect_uri: Optional[str] = None
|
|
22
|
+
elli_audience: Optional[str] = None
|
|
23
|
+
elli_scope: Optional[str] = None
|
|
24
|
+
|
|
25
|
+
class Config:
|
|
26
|
+
env_file = ".env"
|
|
27
|
+
env_file_encoding = "utf-8"
|
|
28
|
+
case_sensitive = False
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# Global settings instance
|
|
32
|
+
settings = Settings()
|
elli_client/models.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Data models for Elli API"""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TokenResponse(BaseModel):
|
|
9
|
+
"""OAuth2 token response from Elli API."""
|
|
10
|
+
|
|
11
|
+
access_token: str # JWT access token for API authentication
|
|
12
|
+
refresh_token: str # Refresh token for obtaining new access tokens
|
|
13
|
+
id_token: str # OpenID Connect ID token
|
|
14
|
+
token_type: str # Token type (usually "Bearer")
|
|
15
|
+
expires_in: int # Token expiration time in seconds
|
|
16
|
+
scope: str # Granted OAuth2 scopes
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ChargingSession(BaseModel):
|
|
20
|
+
"""Charging session data from Elli API."""
|
|
21
|
+
|
|
22
|
+
id: str
|
|
23
|
+
station_id: str
|
|
24
|
+
start_date_time: str
|
|
25
|
+
|
|
26
|
+
# Energy and power data
|
|
27
|
+
energy_consumption_wh: Optional[int] = None
|
|
28
|
+
momentary_charging_speed_watts: Optional[int] = None
|
|
29
|
+
|
|
30
|
+
# Session state
|
|
31
|
+
lifecycle_state: Optional[str] = None
|
|
32
|
+
charging_state: Optional[str] = None
|
|
33
|
+
|
|
34
|
+
# Authentication and authorization
|
|
35
|
+
connector_id: Optional[int] = None
|
|
36
|
+
authentication_method: Optional[str] = None
|
|
37
|
+
authorization_mode: Optional[str] = None
|
|
38
|
+
rfid_card_id: Optional[str] = None
|
|
39
|
+
rfid_card_serial_number: Optional[str] = None
|
|
40
|
+
|
|
41
|
+
# Timestamps
|
|
42
|
+
end_date_time: Optional[str] = None
|
|
43
|
+
last_updated: Optional[str] = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class FirmwareInfo(BaseModel):
|
|
47
|
+
"""Firmware information for a station."""
|
|
48
|
+
|
|
49
|
+
id: str
|
|
50
|
+
version: str
|
|
51
|
+
release_notes_link: Optional[str] = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Station(BaseModel):
|
|
55
|
+
"""Charging station information."""
|
|
56
|
+
|
|
57
|
+
id: str # Unique station identifier
|
|
58
|
+
name: str # Station name
|
|
59
|
+
serial_number: Optional[str] = None # Hardware serial number
|
|
60
|
+
model: Optional[str] = None # Station model (e.g., "Elli Wallbox Pro")
|
|
61
|
+
firmware_version: Optional[str] = None # Current firmware version
|
|
62
|
+
installed_firmware: Optional[FirmwareInfo] = None # Detailed firmware info
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: elli-client
|
|
3
|
+
Version: 1.0.3
|
|
4
|
+
Summary: Python client for Elli Wallbox API
|
|
5
|
+
Author: Marc Szymkowiak
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/marcszy91/elli-client
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/marcszy91/elli-client/issues
|
|
9
|
+
Project-URL: Source Code, https://github.com/marcszy91/elli-client
|
|
10
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: httpx>=0.27.0
|
|
22
|
+
Requires-Dist: pydantic>=2.9.0
|
|
23
|
+
Requires-Dist: pydantic-settings>=2.5.0
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: black>=24.3.0; extra == "dev"
|
|
26
|
+
Requires-Dist: isort>=5.13.2; extra == "dev"
|
|
27
|
+
Requires-Dist: flake8>=7.0.0; extra == "dev"
|
|
28
|
+
Requires-Dist: pytest>=8.1.1; extra == "dev"
|
|
29
|
+
Requires-Dist: pytest-asyncio>=0.23.6; extra == "dev"
|
|
30
|
+
Requires-Dist: pre-commit>=3.7.0; extra == "dev"
|
|
31
|
+
Dynamic: license-file
|
|
32
|
+
|
|
33
|
+
# Elli Client
|
|
34
|
+
|
|
35
|
+
Python client library for the Elli Wallbox API.
|
|
36
|
+
|
|
37
|
+
## Installation
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install elli-client
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Quick Start
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from elli_client import ElliAPIClient
|
|
47
|
+
|
|
48
|
+
# Initialize client and login
|
|
49
|
+
client = ElliAPIClient()
|
|
50
|
+
token = client.login("your.email@example.com", "your_password")
|
|
51
|
+
|
|
52
|
+
# Get charging stations
|
|
53
|
+
stations = client.get_stations()
|
|
54
|
+
for station in stations:
|
|
55
|
+
print(f"Station: {station.name} ({station.id})")
|
|
56
|
+
|
|
57
|
+
# Get active charging session
|
|
58
|
+
session = client.get_active_charging_session()
|
|
59
|
+
if session:
|
|
60
|
+
print(f"Charging: {session.energy_consumption_wh / 1000:.2f} kWh")
|
|
61
|
+
print(f"Power: {session.momentary_power_w} W")
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Features
|
|
65
|
+
|
|
66
|
+
- Authentication with Elli Account
|
|
67
|
+
- Query charging stations
|
|
68
|
+
- Retrieve charging sessions (active and historical)
|
|
69
|
+
- Current charging power and energy consumption
|
|
70
|
+
- Station information
|
|
71
|
+
|
|
72
|
+
## Documentation
|
|
73
|
+
|
|
74
|
+
- **[Quick Start Guide](docs/quick-start.md)** - Get started in minutes
|
|
75
|
+
- **[API Reference](docs/api.md)** - Complete API documentation
|
|
76
|
+
- **[Docs Overview](docs/README.md)** - Documentation index
|
|
77
|
+
|
|
78
|
+
## Development
|
|
79
|
+
|
|
80
|
+
### Setup
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
git clone https://github.com/marcszy91/elli-client
|
|
84
|
+
cd elli-client
|
|
85
|
+
python -m venv .venv
|
|
86
|
+
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
|
87
|
+
pip install -e ".[dev]"
|
|
88
|
+
|
|
89
|
+
# Copy environment template and add your credentials
|
|
90
|
+
cp .env.template .env
|
|
91
|
+
# Edit .env and add your ELLI_EMAIL and ELLI_PASSWORD
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Testing
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
# Format code
|
|
98
|
+
black src/
|
|
99
|
+
isort src/
|
|
100
|
+
|
|
101
|
+
# Run tests (when implemented)
|
|
102
|
+
pytest
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Home Assistant Integration
|
|
106
|
+
|
|
107
|
+
This client is used by the [Elli Charger HACS integration](https://github.com/marcszy91/hacs-elli-charger) for Home Assistant.
|
|
108
|
+
|
|
109
|
+
## License
|
|
110
|
+
|
|
111
|
+
MIT License - see LICENSE file for details
|
|
112
|
+
|
|
113
|
+
## Disclaimer
|
|
114
|
+
|
|
115
|
+
This library was created through reverse engineering of the official Elli iPhone app. It is not officially supported by Elli or Volkswagen Group Charging GmbH. Use at your own risk.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
elli_client/__init__.py,sha256=L5OOtu5ssm4C-S8UNW4NGQIl2RWXelVPzUo3iJ9iyh0,279
|
|
2
|
+
elli_client/client.py,sha256=Vs-H_TA7O-L5o0YOjSm4weE3VdipgrPlBBvAqnbFQ2U,12825
|
|
3
|
+
elli_client/config.py,sha256=iwf9BEwyTa5rK40BZ1Yg9A4xZLtDF4TLJIcLedgJgeA,887
|
|
4
|
+
elli_client/models.py,sha256=uySFwPvZ199x6LJbE6_QBZu0Oje2ECUTbB6_zVCQ-hw,1829
|
|
5
|
+
elli_client-1.0.3.dist-info/licenses/LICENSE,sha256=_j0lKzBDjuE73KOKnXY5090YQF4wgu05gFe4HTKbZkI,1072
|
|
6
|
+
elli_client-1.0.3.dist-info/METADATA,sha256=8F241Z5VqPajVl0oDFDXI8mGscD6GBARBJAlec9cL34,3197
|
|
7
|
+
elli_client-1.0.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
8
|
+
elli_client-1.0.3.dist-info/top_level.txt,sha256=KMmrqD-T6xXIxCwtLQk6QlJ1OOWzyD8vFIQkWcWiQ6k,12
|
|
9
|
+
elli_client-1.0.3.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Marc Szymkowiak
|
|
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 @@
|
|
|
1
|
+
elli_client
|