Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,24 @@ This repository contains a Python module for the Charge Amps' electric vehicle c

The module is developed by [Kirei AB](https://www.kirei.se) and is not supported by [Charge Amps AB](https://chargeamps.com).

## How to use

The simplest way is to install via `pip`, as in:

```
pip install chargeamps
```

If you need access to the organisation API calls, you need to specify this feature upon installation:

```
pip install chargeamps["organisations"]
```

## External API Key

You need an API key to use the Charge Amps external API. Contact [Charge Amps Support](mailto:support@chargeamps.com) for extensive API documentation and API key.


## References

- [Charge Amps External REST API](https://eapi.charge.space/swagger/)
26 changes: 26 additions & 0 deletions chargeamps/external.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
ChargePointStatus,
ChargingSession,
StartAuth,
User,
Partner,
)

API_BASE_URL = "https://eapi.charge.space"
Expand Down Expand Up @@ -47,6 +49,7 @@ def __init__(
self._refresh_token = None
self._token_skew = 30
self._token_lock = asyncio.Lock()
self._user: User | None = None

async def shutdown(self) -> None:
if self._owns_client:
Expand Down Expand Up @@ -80,6 +83,7 @@ async def _exclusive_ensure_token(self) -> None:
except (httpx.HTTPStatusError, httpx.RequestError):
self._logger.warning("Token refresh failed")
self._token = None
self._user = None
self._refresh_token = None
else:
self._token = None
Expand All @@ -97,6 +101,7 @@ async def _exclusive_ensure_token(self) -> None:
except (httpx.HTTPStatusError, httpx.RequestError) as exc:
self._logger.error("Login failed")
self._token = None
self._user = None
self._refresh_token = None
self._token_expire = 0
raise exc
Expand All @@ -107,6 +112,7 @@ async def _exclusive_ensure_token(self) -> None:

response_payload = response.json()
self._token = response_payload["token"]
self._user = response_payload["user"]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
self._refresh_token = response_payload.get("refreshToken", self._refresh_token)
token_payload = jwt.decode(self._token, options={"verify_signature": False})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
self._token_expire = int(token_payload.get("exp", 0))
Expand Down Expand Up @@ -229,6 +235,13 @@ async def set_chargepoint_connector_settings(
)
await self._put(request_uri, json=payload)

async def get_partner(self, charge_point_id: str) -> Partner:
"""Get partner details"""
request_uri = f"/api/{API_VERSION}/chargepoints/{charge_point_id}/partner"
response = await self._get(request_uri)
payload = await response.json()
return Partner.model_validate(payload)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

async def remote_start(
self, charge_point_id: str, connector_id: int, start_auth: StartAuth
) -> None:
Expand All @@ -246,3 +259,16 @@ async def reboot(self, charge_point_id: str) -> None:
"""Reboot chargepoint"""
request_uri = f"/api/{API_VERSION}/chargepoints/{charge_point_id}/reboot"
await self._put(request_uri, json="{}")

async def get_logged_in_user(self) -> User:
"""Get authenticated user info"""
if not self._user or not isinstance(self._user, dict):
raise ValueError("No user is currently logged in")

user_id = self._user["id"]

request_uri = f"/api/{API_VERSION}/users/{user_id}"
response = await self._get(request_uri)
payload = await response.json()

return User.model_validate(payload)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
68 changes: 68 additions & 0 deletions chargeamps/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,16 @@
]


def feature_required(feature_flag):
def decorator(cls):
if feature_flag == "organisations":
return cls
else:
raise ImportError(f"Feature '{feature_flag}' is not enabled")

return decorator
Comment on lines +15 to +22

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Fix the feature_required decorator to correctly enforce feature flags

The current implementation of the feature_required decorator checks if feature_flag is truthy, but since feature_flag is a non-empty string (e.g., "organisations"), this condition will always be true. As a result, the feature gating doesn't function as intended. Instead, the decorator should check whether the feature is enabled in the application settings or configuration.

Consider modifying the decorator to check a global settings object or configuration that maintains the status of feature flags. For example:

 def feature_required(feature_flag):
     def decorator(cls):
-        if feature_flag:
+        if settings.FEATURE_FLAGS.get(feature_flag, False):
             return cls
         else:
             raise ImportError(f"Feature '{feature_flag}' is not enabled")
     return decorator

Ensure that you import the settings module or wherever the feature flags are defined.

Committable suggestion skipped: line range outside the PR's diff.



class FrozenBaseSchema(BaseModel):
model_config = ConfigDict(
alias_generator=to_camel,
Expand Down Expand Up @@ -91,3 +101,61 @@ class StartAuth(FrozenBaseSchema):
rfid_format: str
rfid: str
external_transaction_id: str


class RfidTag(FrozenBaseSchema):
active: bool
rfid: str | None
rfidDec: str | None
rfidDecReverse: str | None


class User(FrozenBaseSchema):
id: str
first_name: str | None
last_name: str | None
email: str | None
mobile: str | None
rfid_tags: list[RfidTag] | None
user_status: str


class Partner(FrozenBaseSchema):
id: int
name: str
description: str
email: str
phone: str


# Only way to register new RFID seems to be through an Organization
@feature_required("organisations")
class Rfid(FrozenBaseSchema):
rfid: str | None
rfidDec: str | None
rfidDecReverse: str | None


@feature_required("organisations")
class Organisation(FrozenBaseSchema):
id: str
name: str
description: str


@feature_required("organisations")
class OrganisationChargingSession(FrozenBaseSchema):
id: int
charge_point_id: str | None
connector_id: int
user_id: str
rfid: str | None
rfidDec: str | None
rfidDecReverse: str | None
organisation_id: str | None
session_type: str
start_time: CustomDateTime | None = None
end_time: CustomDateTime | None = None
external_transaction_id: str | None
total_consumption_kwh: float
external_id: str | None
Loading