Skip to content

Commit aceb4cc

Browse files
authored
Merge pull request #232 from nickknissen/claude/add-dto-value-objects-011CUq5GHi72KLrzdx9JPbrc
Refactor to use DTO/value objects instead of dictionaries
2 parents ce86aec + 618ab21 commit aceb4cc

File tree

8 files changed

+278
-109
lines changed

8 files changed

+278
-109
lines changed

custom_components/monta/api.py

Lines changed: 26 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
STORAGE_REFRESH_EXPIRE_TIME,
2020
STORAGE_REFRESH_TOKEN,
2121
)
22+
from .models import Charge, ChargePoint, TokenResponse, Wallet, WalletTransaction
2223

2324
_LOGGER = logging.getLogger(__name__)
2425

@@ -78,7 +79,7 @@ def __init__(
7879

7980
self._get_token_lock = asyncio.Lock()
8081

81-
async def async_request_token(self) -> any:
82+
async def async_request_token(self) -> TokenResponse:
8283
"""Obtain access token with clientId and secret."""
8384

8485
response_json = await self._api_wrapper(
@@ -87,23 +88,23 @@ async def async_request_token(self) -> any:
8788
data={"clientId": self._client_id, "clientSecret": self._client_secret},
8889
)
8990

90-
return response_json
91+
return TokenResponse.from_dict(response_json)
9192

9293
async def async_authenticate(self) -> str:
9394
"""Obtain access token and store it in preferences."""
9495

95-
response_json = await self.async_request_token()
96+
token_response = await self.async_request_token()
9697

9798
await self._async_update_preferences(
98-
response_json["accessToken"],
99-
dt_util.parse_datetime(response_json["accessTokenExpirationDate"]),
100-
response_json["refreshToken"],
101-
dt_util.parse_datetime(response_json["refreshTokenExpirationDate"]),
99+
token_response.access_token,
100+
token_response.access_token_expiration_date,
101+
token_response.refresh_token,
102+
token_response.refresh_token_expiration_date,
102103
)
103104

104-
return response_json["accessToken"]
105+
return token_response.access_token
105106

106-
async def async_get_charge_points(self) -> any:
107+
async def async_get_charge_points(self) -> dict[int, ChargePoint]:
107108
"""Get available charge points to the user."""
108109

109110
access_token = await self.async_get_access_token()
@@ -115,12 +116,12 @@ async def async_get_charge_points(self) -> any:
115116
)
116117

117118
return {
118-
item["id"]: item
119+
item["id"]: ChargePoint.from_dict(item)
119120
for item in response["data"]
120121
if item.get("serialNumber") is not None
121122
}
122123

123-
async def async_get_charges(self, charge_point_id: int) -> any:
124+
async def async_get_charges(self, charge_point_id: int) -> list[Charge]:
124125
"""Retrieve a list of charge."""
125126

126127
access_token = await self.async_get_access_token()
@@ -137,7 +138,8 @@ async def async_get_charges(self, charge_point_id: int) -> any:
137138
_LOGGER.warning("No charges found in response!")
138139
charges = []
139140

140-
return sorted(charges, key=lambda charge: -charge["id"])
141+
charge_objects = [Charge.from_dict(charge) for charge in charges]
142+
return sorted(charge_objects, key=lambda charge: -charge.id)
141143

142144
async def async_start_charge(self, charge_point_id: int) -> any:
143145
"""Start a charge."""
@@ -172,7 +174,7 @@ async def async_stop_charge(self, charge_id: int) -> any:
172174

173175
return response
174176

175-
async def async_get_wallet_transactions(self) -> any:
177+
async def async_get_wallet_transactions(self) -> list[WalletTransaction]:
176178
"""Retrieve first page of wallet transactions."""
177179

178180
access_token = await self.async_get_access_token()
@@ -189,9 +191,10 @@ async def async_get_wallet_transactions(self) -> any:
189191
_LOGGER.warning("No transactions found in response!")
190192
transactions = []
191193

192-
return sorted(transactions, key=lambda transaction: -transaction["id"])
194+
transaction_objects = [WalletTransaction.from_dict(tx) for tx in transactions]
195+
return sorted(transaction_objects, key=lambda transaction: -transaction.id)
193196

194-
async def async_get_personal_wallet(self) -> any:
197+
async def async_get_personal_wallet(self) -> Wallet:
195198
"""Retrieve personal wallet information."""
196199

197200
access_token = await self.async_get_access_token()
@@ -202,7 +205,7 @@ async def async_get_personal_wallet(self) -> any:
202205
headers={"authorization": f"Bearer {access_token}"},
203206
)
204207

205-
return response
208+
return Wallet.from_dict(response)
206209

207210
async def async_get_access_token(self) -> str:
208211
"""Get access token."""
@@ -224,14 +227,16 @@ async def async_get_access_token(self) -> str:
224227
data=params,
225228
)
226229

230+
token_response = TokenResponse.from_dict(response_json)
231+
227232
await self._async_update_preferences(
228-
response_json["accessToken"],
229-
dt_util.parse_datetime(response_json["accessTokenExpirationDate"]),
230-
response_json["refreshToken"],
231-
dt_util.parse_datetime(response_json["refreshTokenExpirationDate"]),
233+
token_response.access_token,
234+
token_response.access_token_expiration_date,
235+
token_response.refresh_token,
236+
token_response.refresh_token_expiration_date,
232237
)
233238

234-
return response_json["accessToken"]
239+
return token_response.access_token
235240

236241
_LOGGER.debug("No token is valid, Requesting a new tokens")
237242
return await self.async_authenticate()

custom_components/monta/binary_sensor.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121

2222
ENTITY_DESCRIPTIONS = (
2323
BinarySensorEntityDescription(
24-
key="cablePluggedIn",
24+
key="cable_plugged_in",
2525
name="Cable Plugged In",
2626
device_class=BinarySensorDeviceClass.PLUG,
2727
),
@@ -68,6 +68,6 @@ def __init__(
6868
@property
6969
def is_on(self) -> bool:
7070
"""Return true if the binary_sensor is on."""
71-
return self.coordinator.data[self.charge_point_id].get(
72-
self.entity_description.key, False
73-
)
71+
charge_point = self.coordinator.data[self.charge_point_id]
72+
# Get the attribute by name from the ChargePoint DTO
73+
return getattr(charge_point, self.entity_description.key, False)

custom_components/monta/coordinator.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,9 @@ async def _async_update_data(self):
3838
try:
3939
charge_points = await self.client.async_get_charge_points()
4040
for charge_point_id in charge_points:
41-
charge_points[charge_point_id][
42-
"charges"
43-
] = await self.client.async_get_charges(charge_point_id)
41+
charge_points[charge_point_id].charges = (
42+
await self.client.async_get_charges(charge_point_id)
43+
)
4444
return charge_points
4545
except MontaApiClientAuthenticationError as exception:
4646
raise ConfigEntryAuthFailed(exception) from exception
@@ -60,7 +60,7 @@ async def async_stop_charge(self, charge_point_id: int):
6060
"""Stop a charge."""
6161
charges = await self.client.async_get_charges(charge_point_id)
6262
try:
63-
return await self.client.async_stop_charge(charges[0]["id"])
63+
return await self.client.async_stop_charge(charges[0].id)
6464
except MontaApiClientAuthenticationError as exception:
6565
raise ConfigEntryAuthFailed(exception) from exception
6666
except MontaApiClientError as exception:

custom_components/monta/entity.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@ def device_info(self) -> DeviceInfo:
3333
self.charge_point_id,
3434
)
3535
},
36-
name=f"Monta - {chargepoint['name']}",
37-
manufacturer=chargepoint["brandName"],
38-
model=chargepoint["modelName"],
39-
sw_version=chargepoint["firmwareVersion"],
36+
name=f"Monta - {chargepoint.name}",
37+
manufacturer=chargepoint.brand_name,
38+
model=chargepoint.model_name,
39+
sw_version=chargepoint.firmware_version,
4040
)

custom_components/monta/models.py

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
"""Data models for Monta API responses."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import dataclass, field
6+
from datetime import datetime
7+
from typing import Any
8+
9+
from homeassistant.util import dt as dt_util
10+
11+
12+
@dataclass
13+
class Currency:
14+
"""Represents currency information."""
15+
16+
identifier: str
17+
18+
@classmethod
19+
def from_dict(cls, data: dict[str, Any] | None) -> Currency | None:
20+
"""Create a Currency from a dictionary."""
21+
if not data:
22+
return None
23+
return cls(
24+
identifier=data.get("identifier", ""),
25+
)
26+
27+
28+
@dataclass
29+
class Balance:
30+
"""Represents wallet balance information."""
31+
32+
amount: float
33+
credit: float
34+
35+
@classmethod
36+
def from_dict(cls, data: dict[str, Any] | None) -> Balance | None:
37+
"""Create a Balance from a dictionary."""
38+
if not data:
39+
return None
40+
return cls(
41+
amount=data.get("amount", 0.0),
42+
credit=data.get("credit", 0.0),
43+
)
44+
45+
46+
@dataclass
47+
class Wallet:
48+
"""Represents a personal wallet."""
49+
50+
balance: Balance | None
51+
currency: Currency | None
52+
53+
@classmethod
54+
def from_dict(cls, data: dict[str, Any]) -> Wallet:
55+
"""Create a Wallet from a dictionary."""
56+
return cls(
57+
balance=Balance.from_dict(data.get("balance")),
58+
currency=Currency.from_dict(data.get("currency")),
59+
)
60+
61+
62+
@dataclass
63+
class Charge:
64+
"""Represents a charging session."""
65+
66+
id: int
67+
state: str
68+
created_at: datetime | None = None
69+
updated_at: datetime | None = None
70+
started_at: datetime | None = None
71+
stopped_at: datetime | None = None
72+
cable_plugged_in_at: datetime | None = None
73+
fully_charged_at: datetime | None = None
74+
failed_at: datetime | None = None
75+
timeout_at: datetime | None = None
76+
77+
@classmethod
78+
def from_dict(cls, data: dict[str, Any]) -> Charge:
79+
"""Create a Charge from a dictionary."""
80+
return cls(
81+
id=data["id"],
82+
state=data.get("state", ""),
83+
created_at=_parse_datetime(data.get("createdAt")),
84+
updated_at=_parse_datetime(data.get("updatedAt")),
85+
started_at=_parse_datetime(data.get("startedAt")),
86+
stopped_at=_parse_datetime(data.get("stoppedAt")),
87+
cable_plugged_in_at=_parse_datetime(data.get("cablePluggedInAt")),
88+
fully_charged_at=_parse_datetime(data.get("fullyChargedAt")),
89+
failed_at=_parse_datetime(data.get("failedAt")),
90+
timeout_at=_parse_datetime(data.get("timeoutAt")),
91+
)
92+
93+
def to_dict(self) -> dict[str, Any]:
94+
"""Convert Charge to a dictionary for compatibility."""
95+
return {
96+
"id": self.id,
97+
"state": self.state,
98+
"createdAt": self.created_at,
99+
"updatedAt": self.updated_at,
100+
"startedAt": self.started_at,
101+
"stoppedAt": self.stopped_at,
102+
"cablePluggedInAt": self.cable_plugged_in_at,
103+
"fullyChargedAt": self.fully_charged_at,
104+
"failedAt": self.failed_at,
105+
"timeoutAt": self.timeout_at,
106+
}
107+
108+
109+
@dataclass
110+
class ChargePoint:
111+
"""Represents a charge point (charging station)."""
112+
113+
id: int
114+
name: str
115+
serial_number: str | None
116+
type: str
117+
state: str
118+
visibility: str
119+
last_meter_reading_kwh: float
120+
brand_name: str
121+
model_name: str
122+
firmware_version: str
123+
cable_plugged_in: bool = False
124+
charges: list[Charge] = field(default_factory=list)
125+
126+
@classmethod
127+
def from_dict(cls, data: dict[str, Any]) -> ChargePoint:
128+
"""Create a ChargePoint from a dictionary."""
129+
# Parse charges if they exist in the data
130+
charges_data = data.get("charges", [])
131+
charges = [Charge.from_dict(charge) for charge in charges_data] if charges_data else []
132+
133+
return cls(
134+
id=data["id"],
135+
name=data.get("name", ""),
136+
serial_number=data.get("serialNumber"),
137+
type=data.get("type", ""),
138+
state=data.get("state", ""),
139+
visibility=data.get("visibility", ""),
140+
last_meter_reading_kwh=data.get("lastMeterReadingKwh", 0.0),
141+
brand_name=data.get("brandName", ""),
142+
model_name=data.get("modelName", ""),
143+
firmware_version=data.get("firmwareVersion", ""),
144+
cable_plugged_in=data.get("cablePluggedIn", False),
145+
charges=charges,
146+
)
147+
148+
149+
@dataclass
150+
class WalletTransaction:
151+
"""Represents a wallet transaction."""
152+
153+
id: int
154+
state: str
155+
created_at: datetime | None = None
156+
updated_at: datetime | None = None
157+
completed_at: datetime | None = None
158+
159+
@classmethod
160+
def from_dict(cls, data: dict[str, Any]) -> WalletTransaction:
161+
"""Create a WalletTransaction from a dictionary."""
162+
return cls(
163+
id=data["id"],
164+
state=data.get("state", ""),
165+
created_at=_parse_datetime(data.get("createdAt")),
166+
updated_at=_parse_datetime(data.get("updatedAt")),
167+
completed_at=_parse_datetime(data.get("completedAt")),
168+
)
169+
170+
def to_dict(self) -> dict[str, Any]:
171+
"""Convert WalletTransaction to a dictionary for compatibility."""
172+
return {
173+
"id": self.id,
174+
"state": self.state,
175+
"createdAt": self.created_at,
176+
"updatedAt": self.updated_at,
177+
"completedAt": self.completed_at,
178+
}
179+
180+
181+
@dataclass
182+
class TokenResponse:
183+
"""Represents an authentication token response."""
184+
185+
access_token: str
186+
access_token_expiration_date: datetime
187+
refresh_token: str
188+
refresh_token_expiration_date: datetime
189+
user_id: str | None = None
190+
191+
@classmethod
192+
def from_dict(cls, data: dict[str, Any]) -> TokenResponse:
193+
"""Create a TokenResponse from a dictionary."""
194+
return cls(
195+
access_token=data["accessToken"],
196+
access_token_expiration_date=dt_util.parse_datetime(
197+
data["accessTokenExpirationDate"]
198+
),
199+
refresh_token=data["refreshToken"],
200+
refresh_token_expiration_date=dt_util.parse_datetime(
201+
data["refreshTokenExpirationDate"]
202+
),
203+
user_id=data.get("userId"),
204+
)
205+
206+
207+
def _parse_datetime(date_string: str | None) -> datetime | None:
208+
"""Parse a datetime string to a datetime object."""
209+
if not date_string:
210+
return None
211+
if isinstance(date_string, datetime):
212+
return date_string
213+
return dt_util.parse_datetime(date_string)

0 commit comments

Comments
 (0)