Skip to content

Commit 53e08b6

Browse files
marq24marq24
authored andcommitted
- 12V battery report as None (and not -1) if not available in the data
- added additional elvehtargetcharge ALT1 + ALT2 - added location name to elvehtargetcharge select entity (name) - elvehtargetcharge now a select
1 parent d0bd845 commit 53e08b6

10 files changed

Lines changed: 301 additions & 65 deletions

File tree

custom_components/fordpass/__init__.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import logging
44
# import threading
55
from datetime import timedelta
6-
from typing import Final
6+
from typing import Final, Any
77

88
import aiohttp
99
import async_timeout
@@ -15,7 +15,7 @@
1515
from homeassistant.helpers.aiohttp_client import async_create_clientsession
1616
from homeassistant.helpers.entity import EntityDescription
1717
from homeassistant.helpers.event import async_track_time_interval
18-
from homeassistant.helpers.typing import UNDEFINED
18+
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
1919
from homeassistant.helpers.update_coordinator import CoordinatorEntity, DataUpdateCoordinator, UpdateFailed
2020
from homeassistant.util.unit_system import UnitSystem
2121

@@ -545,6 +545,7 @@ class FordPassEntity(CoordinatorEntity):
545545
"""Defines a base FordPass entity."""
546546
_attr_should_poll = False
547547
_attr_has_entity_name = True
548+
_attr_name_addon = None
548549

549550
def __init__(self, a_tag: Tag, coordinator: FordPassDataUpdateCoordinator, description: EntityDescription | None = None):
550551
"""Initialize the entity."""
@@ -558,10 +559,19 @@ def __init__(self, a_tag: Tag, coordinator: FordPassDataUpdateCoordinator, descr
558559
if hasattr(description, "translation_key") and description.translation_key is not None:
559560
self._attr_translation_key = description.translation_key.lower()
560561

562+
if hasattr(description, "name_addon"):
563+
self._attr_name_addon = description.name_addon
564+
561565
self.coordinator: FordPassDataUpdateCoordinator = coordinator
562566
self.entity_id = f"{DOMAIN}.fordpass_{self.coordinator._vin.lower()}_{a_tag.key}"
563567
self._tag = a_tag
564568

569+
def _name_internal(self, device_class_name: str | None, platform_translations: dict[str, Any], ) -> str | UndefinedType | None:
570+
tmp = super()._name_internal(device_class_name, platform_translations)
571+
if self._attr_name_addon is not None:
572+
return f"{self._attr_name_addon} {tmp}"
573+
else:
574+
return tmp
565575

566576
@property
567577
def device_id(self):

custom_components/fordpass/const.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,8 @@
176176
RCC_SEAT_OPTIONS_FULL: Final = ["off", "heated1", "heated2", "heated3", "cooled1", "cooled2", "cooled3"]
177177
RCC_SEAT_OPTIONS_HEAT_ONLY: Final = ["off", "heated1", "heated2", "heated3"]
178178

179+
ELVEH_TARGET_CHARGE_OPTIONS: Final = ["50", "60", "70", "80", "85", "90", "95", "100"]
180+
179181
VEHICLE_LOCK_STATE_LOCKED: Final = "LOCKED"
180182
VEHICLE_LOCK_STATE_PARTLY: Final = "PARTLY_LOCKED"
181183
VEHICLE_LOCK_STATE_UNLOCKED: Final = "UNLOCKED"

custom_components/fordpass/const_tags.py

Lines changed: 48 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@
1212
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
1313
from homeassistant.util.unit_system import UnitSystem
1414

15-
from custom_components.fordpass.const import ZONE_LIGHTS_OPTIONS, RCC_SEAT_OPTIONS_FULL
16-
from custom_components.fordpass.fordpass_handler import FordpassDataHandler
15+
from custom_components.fordpass.const import ZONE_LIGHTS_OPTIONS, RCC_SEAT_OPTIONS_FULL, ELVEH_TARGET_CHARGE_OPTIONS
16+
from custom_components.fordpass.fordpass_handler import FordpassDataHandler, UNSUPPORTED
1717

1818
_LOGGER = logging.getLogger(__name__)
1919

@@ -53,12 +53,16 @@ async def turn_on_off(self, data, vehicle, turn_on:bool) -> bool:
5353

5454
async def async_select_option(self, data, vehicle, new_value: Any) -> bool:
5555
if self.select_fn:
56-
return await self.select_fn(data, vehicle, new_value, self.get_state(data))
56+
current_value = self.get_state(data)
57+
if current_value is not UNSUPPORTED:
58+
return await self.select_fn(data, vehicle, new_value, current_value)
5759
return None
5860

5961
async def async_set_value(self, data, vehicle, new_value: str) -> bool:
6062
if self.select_fn:
61-
return await self.select_fn(data, vehicle, new_value, self.get_state(data))
63+
current_value = self.get_state(data)
64+
if current_value is not UNSUPPORTED:
65+
return await self.select_fn(data, vehicle, new_value, current_value)
6266
return None
6367

6468
async def async_push(self, coordinator, vehicle) -> bool:
@@ -143,15 +147,22 @@ async def async_push(self, coordinator, vehicle) -> bool:
143147
state_fn=lambda data: FordpassDataHandler.get_rcc_state(data, rcc_key="RccRightFrontClimateSeat_Rq"),
144148
select_fn=FordpassDataHandler.set_rcc_RccRightFrontClimateSeat_Rq)
145149

150+
151+
ELVEH_TARGET_CHARGE = ApiKey(key="elVehTargetCharge",
152+
state_fn=lambda data: FordpassDataHandler.get_elev_target_charge_state(data, 0),
153+
select_fn=FordpassDataHandler.set_elev_target_charge)
154+
ELVEH_TARGET_CHARGE_ALT1 = ApiKey(key="elVehTargetChargeAlt1",
155+
state_fn=lambda data: FordpassDataHandler.get_elev_target_charge_state(data, 1),
156+
select_fn=FordpassDataHandler.set_elev_target_charge_alt1)
157+
ELVEH_TARGET_CHARGE_ALT2 = ApiKey(key="elVehTargetChargeAlt2",
158+
state_fn=lambda data: FordpassDataHandler.get_elev_target_charge_state(data, 2),
159+
select_fn=FordpassDataHandler.set_elev_target_charge_alt2)
160+
146161
# NUMBERS
147162
RCC_TEMPERATURE = ApiKey(key="rccTemperature",
148163
state_fn=lambda data: FordpassDataHandler.get_rcc_state(data, rcc_key="SetPointTemp_Rq"),
149164
select_fn=FordpassDataHandler.set_rcc_SetPointTemp_Rq)
150165

151-
ELVEH_TARGET_CHARGE = ApiKey(key="elVehTargetCharge",
152-
state_fn=lambda data: FordpassDataHandler.get_elev_target_charge_state(data),
153-
select_fn=FordpassDataHandler.set_elev_target_charge)
154-
155166
# SENSORS
156167
##################################################
157168
ODOMETER = ApiKey(key="odometer",
@@ -161,7 +172,7 @@ async def async_push(self, coordinator, vehicle) -> bool:
161172
state_fn=FordpassDataHandler.get_fuel_state,
162173
attrs_fn=FordpassDataHandler.get_fuel_attrs)
163174
BATTERY = ApiKey(key="battery",
164-
state_fn=lambda data: round(FordpassDataHandler.get_value_for_metrics_key(data, "batteryStateOfCharge", -1)),
175+
state_fn=FordpassDataHandler.get_battery_state,
165176
attrs_fn=FordpassDataHandler.get_battery_attrs)
166177
OIL = ApiKey(key="oil",
167178
state_fn=lambda data: FordpassDataHandler.get_value_for_metrics_key(data, "oilLifeRemaining", None),
@@ -293,19 +304,23 @@ async def async_push(self, coordinator, vehicle) -> bool:
293304
@dataclass(frozen=True)
294305
class ExtButtonEntityDescription(ButtonEntityDescription):
295306
tag: Tag | None = None
307+
name_addon: str | None = None
296308

297309
@dataclass(frozen=True)
298310
class ExtSensorEntityDescription(SensorEntityDescription):
299311
tag: Tag | None = None
300312
skip_existence_check: bool | None = None
313+
name_addon: str | None = None
301314

302315
@dataclass(frozen=True)
303316
class ExtSelectEntityDescription(SelectEntityDescription):
304317
tag: Tag | None = None
318+
name_addon: str | None = None
305319

306320
@dataclass(frozen=True)
307321
class ExtNumberEntityDescription(NumberEntityDescription):
308322
tag: Tag | None = None
323+
name_addon: str | None = None
309324

310325

311326
SENSORS = [
@@ -705,6 +720,30 @@ class ExtNumberEntityDescription(NumberEntityDescription):
705720
options=RCC_SEAT_OPTIONS_FULL,
706721
has_entity_name=True,
707722
),
723+
ExtSelectEntityDescription(
724+
tag=Tag.ELVEH_TARGET_CHARGE,
725+
key=Tag.ELVEH_TARGET_CHARGE.key,
726+
icon="mdi:battery-charging-high",
727+
options=ELVEH_TARGET_CHARGE_OPTIONS,
728+
has_entity_name=True,
729+
),
730+
ExtSelectEntityDescription(
731+
tag=Tag.ELVEH_TARGET_CHARGE_ALT1,
732+
key=Tag.ELVEH_TARGET_CHARGE_ALT1.key,
733+
icon="mdi:battery-charging-high",
734+
options=ELVEH_TARGET_CHARGE_OPTIONS,
735+
has_entity_name=True,
736+
entity_registry_enabled_default=False,
737+
),
738+
ExtSelectEntityDescription(
739+
tag=Tag.ELVEH_TARGET_CHARGE_ALT2,
740+
key=Tag.ELVEH_TARGET_CHARGE_ALT2.key,
741+
icon="mdi:battery-charging-high",
742+
options=ELVEH_TARGET_CHARGE_OPTIONS,
743+
has_entity_name=True,
744+
entity_registry_enabled_default=False,
745+
),
746+
708747
]
709748
NUMBERS = [
710749
ExtNumberEntityDescription(
@@ -717,16 +756,5 @@ class ExtNumberEntityDescription(NumberEntityDescription):
717756
native_step=0.5,
718757
mode=NumberMode.BOX,
719758
has_entity_name=True,
720-
),
721-
ExtNumberEntityDescription(
722-
tag=Tag.ELVEH_TARGET_CHARGE,
723-
key=Tag.ELVEH_TARGET_CHARGE.key,
724-
icon="mdi:battery-charging-high",
725-
native_unit_of_measurement=PERCENTAGE,
726-
native_min_value=20,
727-
native_max_value=100,
728-
native_step=5,
729-
mode=NumberMode.SLIDER,
730-
has_entity_name=True,
731759
)
732760
]

custom_components/fordpass/fordpass_bridge.py

Lines changed: 112 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
ROOT_MESSAGES,
2525
ROOT_VEHICLES,
2626
ROOT_REMOTE_CLIMATE_CONTROL,
27-
ROOT_ENERGY_TRANSFER_STATUS,
27+
ROOT_PREFERRED_CHARGE_TIMES,
2828
ROOT_UPDTIME
2929
)
3030

@@ -130,7 +130,7 @@ class ConnectedFordPassVehicle:
130130
_LAST_MESSAGES_UPDATE: float = 0.0
131131
_last_ignition_state: str | None = None
132132
_ws_debounced_full_refresh_task: asyncio.Task | None = None
133-
133+
_ws_debounced_preferred_charge_times_refresh_task: asyncio.Task | None = None
134134
# when you have multiple vehicles, you need to set the vehicle log id
135135
# (v)ehicle (l)og (i)d
136136
vli: str = ""
@@ -190,13 +190,14 @@ def __init__(self, web_session, username, vin, region_key, coordinator: DataUpda
190190
self._cached_vehicles_data = {}
191191
self._remote_climate_control_supported = None
192192
self._cached_rcc_data = {}
193-
self._energy_transfer_status_supported = None
194-
self._cached_ets_data = {}
193+
self._preferred_charge_times_supported = None
194+
self._cached_pct_data = {}
195195
self.coordinator = coordinator
196196

197197
# websocket connection related variables
198198
self._ws_debounced_update_task = None
199199
self._ws_debounced_full_refresh_task = None
200+
self._ws_debounced_preferred_charge_times_refresh_task = None
200201
self._ws_in_use_access_token = None
201202
self.ws_connected = False
202203
self._ws_LAST_UPDATE = 0
@@ -866,7 +867,10 @@ def _ws_update_key(self, data_obj, a_root_key, collected_keys):
866867
# we have a special handling for the 'updateChargeProfilesCommand'
867868
# -> when we receive a 'success' state, we will update our
868869
# energy_transfer_object...
869-
asyncio.create_task(self.update_energy_transfer_status_int())
870+
if self._ws_debounced_preferred_charge_times_refresh_task is not None and not self._ws_debounced_preferred_charge_times_refresh_task.done():
871+
self._ws_debounced_preferred_charge_times_refresh_task.cancel()
872+
_LOGGER.debug(f"{self.vli}ws(): updateChargeProfilesCommand -> triggering 'preferred_charge_times' data update (will be started in 30sec)")
873+
self._ws_debounced_preferred_charge_times_refresh_task = asyncio.create_task(self._ws_debounce_update_preferred_charge_times())
870874

871875
else:
872876
_LOGGER.debug(f"{self.vli}ws(): new state (without toState) '{a_state_name}' arrived: {a_value_obj}")
@@ -1081,7 +1085,7 @@ async def update_all(self):
10811085
self._remote_climate_control_supported = a_vehicle_profile["remoteClimateControl"]
10821086

10831087
if "showEVBatteryLevel" in a_vehicle_profile:
1084-
self._energy_transfer_status_supported = a_vehicle_profile["showEVBatteryLevel"]
1088+
self._preferred_charge_times_supported = a_vehicle_profile["showEVBatteryLevel"]
10851089

10861090
break
10871091

@@ -1095,13 +1099,13 @@ async def update_all(self):
10951099
data[ROOT_REMOTE_CLIMATE_CONTROL] = self._cached_rcc_data
10961100

10971101
# only update energy-status if not present yet
1098-
if self._energy_transfer_status_supported:
1099-
if self._cached_ets_data is None or len(self._cached_ets_data) == 0:
1100-
_LOGGER.debug(f"{self.vli}update_all(): request 'energy transfer status' data...")
1101-
self._cached_ets_data = await self.req_energy_transfer_status()
1102+
if self._preferred_charge_times_supported:
1103+
if self._cached_pct_data is None or len(self._cached_pct_data) == 0:
1104+
_LOGGER.debug(f"{self.vli}update_all(): request 'preferred_charge_times' data...")
1105+
self._cached_pct_data = await self.req_preferred_charge_times()
11021106

1103-
if self._cached_ets_data is not None and len(self._cached_ets_data) > 0:
1104-
data[ROOT_ENERGY_TRANSFER_STATUS] = self._cached_ets_data
1107+
if self._cached_pct_data is not None and len(self._cached_pct_data) > 0:
1108+
data[ROOT_PREFERRED_CHARGE_TIMES] = self._cached_pct_data
11051109

11061110

11071111
# ok finally store the data in our main data container...
@@ -1120,14 +1124,32 @@ async def update_remote_climate_int(self):
11201124
self._data_container[ROOT_REMOTE_CLIMATE_CONTROL] = self._cached_rcc_data
11211125

11221126

1123-
async def update_energy_transfer_status_int(self):
1127+
async def update_preferred_charge_times_int(self):
11241128
# only update remote climate data if not present yet
1125-
if self._energy_transfer_status_supported:
1126-
_LOGGER.debug(f"{self.vli}update_energy_transfer_status_int(): request 'energy transfer status' data...")
1127-
self._cached_ets_data = await self.req_energy_transfer_status()
1129+
if self._preferred_charge_times_supported:
1130+
_LOGGER.debug(f"{self.vli}update_preferred_charge_times_int(): request 'energy transfer status' data...")
1131+
self._cached_pct_data = await self.req_preferred_charge_times()
1132+
1133+
if self._cached_pct_data is not None and len(self._cached_pct_data) > 0:
1134+
self._data_container[ROOT_PREFERRED_CHARGE_TIMES] = self._cached_pct_data
1135+
return True
1136+
1137+
return False
1138+
1139+
async def _ws_debounce_update_preferred_charge_times(self):
1140+
if self._preferred_charge_times_supported:
1141+
try:
1142+
_LOGGER.debug(f"{self.vli}_ws_debounce_update_preferred_charge_times(): started")
1143+
await asyncio.sleep(30)
1144+
_LOGGER.debug(f"{self.vli}_ws_debounce_update_preferred_charge_times(): starting the 'update_preferred_charge_times_int()' update now")
1145+
success = await self.update_preferred_charge_times_int()
1146+
if success and self.coordinator is not None:
1147+
self.coordinator.async_set_updated_data(self._data_container)
11281148

1129-
if self._cached_ets_data is not None and len(self._cached_ets_data) > 0:
1130-
self._data_container[ROOT_ENERGY_TRANSFER_STATUS] = self._cached_ets_data
1149+
except CancelledError:
1150+
_LOGGER.debug(f"{self.vli}_ws_debounce_update_preferred_charge_times(): was canceled - all good")
1151+
except BaseException as ex:
1152+
_LOGGER.warning(f"{self.vli}_ws_debounce_update_preferred_charge_times(): Error during 'preferred_charge_times' data refresh - {type(ex)} - {ex}")
11311153

11321154

11331155
async def req_status(self):
@@ -1384,7 +1406,79 @@ async def req_remote_climate(self):
13841406
self._HAS_COM_ERROR = True
13851407
return None
13861408

1409+
async def req_preferred_charge_times(self):
1410+
global _FOUR_NULL_ONE_COUNTER
1411+
try:
1412+
await self.__ensure_valid_tokens()
1413+
if self._HAS_COM_ERROR:
1414+
_LOGGER.debug(f"{self.vli}req_preferred_charge_times(): - COMM ERROR")
1415+
return None
1416+
else:
1417+
_LOGGER.debug(f"{self.vli}req_preferred_charge_times(): - access_token exist? {self.access_token is not None}")
1418+
1419+
# and the 'preferred-charge-times' request will get a 'vin' in the header
1420+
headers_veh = {
1421+
**apiHeaders,
1422+
"auth-token": self.access_token,
1423+
"Application-Id": self.app_id,
1424+
"vin": self.vin
1425+
}
1426+
response_pct = await self.session.get(
1427+
f"{FORD_VEHICLE_API}/electrification/experiences/v2/vehicles/preferred-charge-times",
1428+
headers=headers_veh,
1429+
timeout=self.timeout
1430+
)
1431+
if response_pct.status == 200:
1432+
# ok first resetting the counter for 401 errors (if we had any)
1433+
_FOUR_NULL_ONE_COUNTER[self.vin] = 0
1434+
1435+
result_pct = await response_pct.json()
1436+
if self._LOCAL_LOGGING:
1437+
await self._local_logging("pct", result_pct)
1438+
1439+
# we are going to transform our result! - we create a dict with the 'location.id' as a key
1440+
if isinstance(result_pct, list):
1441+
modified_result = {}
1442+
for a_entry in result_pct:
1443+
if "vin" in a_entry:
1444+
if a_entry["vin"].upper() == self.vin.upper():
1445+
modified_result[a_entry["location"]["id"]] = a_entry
1446+
result_pct = modified_result
1447+
else:
1448+
_LOGGER.warning(f"{self.vli}req_preferred_charge_times(): received unexpected data format: {type(result_pct)} - expected a list of entries")
1449+
1450+
#_LOGGER.error(f"--------------------------")
1451+
#_LOGGER.error(f"--------------------------")
1452+
#_LOGGER.error(f"{self.vli}req_preferred_charge_times(): received data: {result_pct}")
1453+
#_LOGGER.error(f"--------------------------")
1454+
#_LOGGER.error(f"--------------------------")
1455+
return result_pct
1456+
1457+
elif response_pct.status == 401:
1458+
_FOUR_NULL_ONE_COUNTER[self.vin] += 1
1459+
if _FOUR_NULL_ONE_COUNTER[self.vin] > MAX_401_RESPONSE_COUNT:
1460+
_LOGGER.error(f"{self.vli}req_preferred_charge_times(): status_code: 401 - mark_re_auth_required()")
1461+
self.mark_re_auth_required()
1462+
else:
1463+
(_LOGGER.warning if _FOUR_NULL_ONE_COUNTER[self.vin] > 2 else _LOGGER.info)(f"{self.vli}req_preferred_charge_times(): status_code: 401 - counter: {_FOUR_NULL_ONE_COUNTER}")
1464+
await asyncio.sleep(5)
1465+
1466+
return None
1467+
else:
1468+
_LOGGER.info(f"{self.vli}req_preferred_charge_times(): status_code: {response_pct.status} - {response_pct.real_url} - Received response: {await response_pct.text()}")
1469+
self._HAS_COM_ERROR = True
1470+
return None
1471+
1472+
except BaseException as e:
1473+
if not await self.__check_for_closed_session(e):
1474+
_LOGGER.warning(f"{self.vli}req_preferred_charge_times(): Error while '_request_token' for vehicle {self.vin} - {type(e)} - {e}")
1475+
else:
1476+
_LOGGER.info(f"{self.vli}req_preferred_charge_times(): RuntimeError - Session was closed occurred - but a new Session could be generated")
1477+
self._HAS_COM_ERROR = True
1478+
return None
1479+
13871480
async def req_energy_transfer_status(self):
1481+
# this function will only return a valid object if the vehicle is located at a KNOWN charging location
13881482
global _FOUR_NULL_ONE_COUNTER
13891483
try:
13901484
await self.__ensure_valid_tokens()
@@ -1440,7 +1534,6 @@ async def req_energy_transfer_status(self):
14401534
self._HAS_COM_ERROR = True
14411535
return None
14421536

1443-
14441537
# ***********************************************************
14451538
# ***********************************************************
14461539
# ***********************************************************

0 commit comments

Comments
 (0)