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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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