Skip to content

Commit e65a8dc

Browse files
committed
minor adjustments to the 'PR' from TheHangMan97
Adding FIRMWAREUPG_IN_PROGRESS, OTA_SCHEDULE & LAST_FIRMWARE_UPDATE (#238) * Add OTA/firmware update sensors sourced from states.deployment Fixes the dead firmwareUpgInProgress sensor (previously read a metrics key that never populates) to report real IVSU deployment progress (artifact_retrieval_in_progress -> installation_queued -> deploying -> success), including ETA and 12V battery SoC during install. Falls back to the less frequent configurationUpdate record when no deployment is in flight. Also adds otaSchedule (next scheduled activation window) and lastFirmwareUpdate (last completed ECU update + part numbers), both parsed from data already present in the existing state sync. * Add persistent firmware update history sensor Ford doesn't expose a history endpoint for firmware/ECU updates (unlike e.g. energyTransferLogs), so this adds a dedicated stateful sensor (FordPassFirmwareUpdateHistorySensor) that watches events.configurationUpdateEvent for new updateTime values, appends them to a capped list (last 20), and persists it across HA restarts via RestoreEntity. State is the entry count, attributes hold the full list (newest first). --------- authored-by: TheHangMan97
1 parent ff9e676 commit e65a8dc

11 files changed

Lines changed: 305 additions & 27 deletions

File tree

custom_components/fordpass/const_tags.py

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -365,10 +365,6 @@ async def async_push(self, coordinator, vehicle) -> bool:
365365

366366
DEEPSLEEP_IN_PROGRESS = ApiKey(key="deepSleepInProgress",
367367
state_fn=lambda data, prev_state: FordpassDataHandler.get_value_for_metrics_key(data, "deepSleepInProgress"))
368-
FIRMWAREUPG_IN_PROGRESS = ApiKey(key="firmwareUpgInProgress",
369-
state_fn=lambda data, prev_state: FordpassDataHandler.get_value_for_metrics_key(data, "firmwareUpgradeInProgress"),
370-
attrs_fn=lambda data, units: FordpassDataHandler.get_metrics_dict(data, "firmwareUpgradeInProgress"))
371-
372368

373369
LAST_ENERGY_CONSUMED= ApiKey(key="lastEnergyConsumed",
374370
state_fn=FordpassDataHandler.get_last_energy_consumed_state,
@@ -382,6 +378,28 @@ async def async_push(self, coordinator, vehicle) -> bool:
382378
state_fn=FordpassDataHandler.get_departure_schedules_state,
383379
attrs_fn=FordpassDataHandler.get_departure_schedules_attrs)
384380

381+
# currently not used!
382+
FIRMWAREUPG_IN_PROGRESS = ApiKey(key="firmwareUpgInProgress",
383+
state_fn=lambda data, prev_state: FordpassDataHandler.get_value_for_metrics_key(data, "firmwareUpgradeInProgress"),
384+
attrs_fn=lambda data, units: FordpassDataHandler.get_metrics_dict(data, "firmwareUpgradeInProgress"))
385+
386+
FIRMWAREUPG_STATUS = ApiKey(key="firmwareUpgStatus",
387+
state_fn=FordpassDataHandler.get_firmware_update_status_state,
388+
attrs_fn=FordpassDataHandler.get_firmware_update_status_attrs)
389+
390+
OTA_SCHEDULE = ApiKey(key="otaSchedule",
391+
state_fn=FordpassDataHandler.get_ota_schedule_state,
392+
attrs_fn=FordpassDataHandler.get_ota_schedule_attrs)
393+
394+
LAST_FIRMWARE_UPDATE = ApiKey(key="lastFirmwareUpdate",
395+
state_fn=FordpassDataHandler.get_last_firmware_update_state,
396+
attrs_fn=FordpassDataHandler.get_last_firmware_update_attrs)
397+
398+
# state/attrs are not used - this tag is backed by a dedicated stateful sensor class
399+
# (FordPassFirmwareUpdateHistorySensor) that accumulates completed updates across coordinator
400+
# refreshes and persists them via RestoreEntity, since Ford does not expose a history list for this
401+
FIRMWARE_UPDATE_HISTORY = ApiKey(key="firmwareUpdateHistory")
402+
385403

386404
# Debug Sensors (Disabled by default)
387405
EVENTS = ApiKey(key="events",
@@ -936,6 +954,39 @@ class ExtNumberEntityDescription(NumberEntityDescription):
936954
has_entity_name=True,
937955
entity_registry_enabled_default=True
938956
),
957+
ExtSensorEntityDescription(
958+
tag=Tag.FIRMWAREUPG_STATUS,
959+
key=Tag.FIRMWAREUPG_STATUS.key,
960+
icon="mdi:memory-arrow-down",
961+
has_entity_name=True,
962+
entity_category=EntityCategory.DIAGNOSTIC
963+
),
964+
ExtSensorEntityDescription(
965+
tag=Tag.OTA_SCHEDULE,
966+
key=Tag.OTA_SCHEDULE.key,
967+
icon="mdi:update",
968+
device_class=SensorDeviceClass.TIMESTAMP,
969+
has_entity_name=True,
970+
entity_category=EntityCategory.DIAGNOSTIC
971+
),
972+
ExtSensorEntityDescription(
973+
tag=Tag.LAST_FIRMWARE_UPDATE,
974+
key=Tag.LAST_FIRMWARE_UPDATE.key,
975+
icon="mdi:chip",
976+
device_class=SensorDeviceClass.TIMESTAMP,
977+
skip_existence_check=True,
978+
has_entity_name=True,
979+
entity_category=EntityCategory.DIAGNOSTIC
980+
),
981+
ExtSensorEntityDescription(
982+
tag=Tag.FIRMWARE_UPDATE_HISTORY,
983+
key=Tag.FIRMWARE_UPDATE_HISTORY.key,
984+
icon="mdi:history",
985+
native_unit_of_measurement="updates",
986+
skip_existence_check=True,
987+
has_entity_name=True,
988+
entity_category=EntityCategory.DIAGNOSTIC
989+
),
939990

940991

941992
# Debug sensors (disabled by default)

custom_components/fordpass/fordpass_handler.py

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1541,6 +1541,154 @@ async def on_off_departure_times(data, vehicle, turn_on:bool) -> bool:
15411541
else:
15421542
return await vehicle.departure_times_disable()
15431543

1544+
# FIRMWARE / OTA UPDATE SENSORS
1545+
# the actual IVSU installation progress lives under states.deployment - it walks through
1546+
# artifact_retrieval_in_progress -> installation_queued -> deploying -> success (or failure), and
1547+
# while "deploying" the message payload carries an ETA + 12V battery telemetry (Ford aborts the
1548+
# install if the 12V battery is too weak). states.configurationUpdate is a separate, much less
1549+
# frequent config-sync record that we fall back to if no deployment has been seen yet
1550+
def get_firmware_update_status_state(data, prev_state=None):
1551+
states = FordpassDataHandler.get_states(data)
1552+
to_state = states.get("deployment", {}).get("value", {}).get("toState")
1553+
if to_state is None:
1554+
to_state = states.get("configurationUpdate", {}).get("value", {}).get("toState")
1555+
return to_state if to_state is not None else UNSUPPORTED
1556+
1557+
def get_firmware_update_status_attrs(data, units):
1558+
states = FordpassDataHandler.get_states(data)
1559+
deployment = states.get("deployment", {})
1560+
attrs = {}
1561+
1562+
if deployment:
1563+
value = deployment.get("value", {})
1564+
attrs["fromState"] = value.get("fromState")
1565+
attrs["timestamp"] = deployment.get("timestamp")
1566+
attrs["commandId"] = deployment.get("commandId")
1567+
1568+
try:
1569+
telemetry = json.loads(deployment.get("message", ""))
1570+
except (ValueError, TypeError):
1571+
telemetry = None
1572+
if isinstance(telemetry, dict):
1573+
if "estimatedTimeToActivate" in telemetry:
1574+
attrs["estimatedSecondsToActivate"] = telemetry["estimatedTimeToActivate"]
1575+
if "currentLvBatterySoc" in telemetry:
1576+
attrs["lvBatterySocDuringUpdate"] = telemetry["currentLvBatterySoc"]
1577+
1578+
config_update = states.get("configurationUpdate", {})
1579+
if config_update:
1580+
attrs["lastConfigurationUpdateTimestamp"] = config_update.get("timestamp")
1581+
1582+
return attrs
1583+
1584+
# the weekly OTA activation window is only available via states.commands.getASUSettingsCommand -
1585+
# this gets populated whenever ANY client (FordPass app or us) asked Ford for it, we don't have to
1586+
# request it ourselves
1587+
def get_ota_schedule_state(data, prev_state=None):
1588+
oem_data = (FordpassDataHandler.get_states(data)
1589+
.get("commands", {})
1590+
.get("getASUSettingsCommand", {})
1591+
.get("value", {})
1592+
.get("oemData", {}))
1593+
day_schedule_strs = oem_data.get("OTAActivationDaySchedule", {}).get("stringArrayValue", [])
1594+
if not day_schedule_strs:
1595+
return None
1596+
1597+
now = datetime.now()
1598+
next_activation = None
1599+
for entry_str in day_schedule_strs:
1600+
try:
1601+
entry = json.loads(entry_str)
1602+
except (ValueError, TypeError):
1603+
continue
1604+
1605+
day_of_week = entry.get("activationDayOfWeek", "").upper()
1606+
if day_of_week not in DAYS_MAP:
1607+
continue
1608+
1609+
for a_time in entry.get("activationScheduleTime", []):
1610+
target_weekday = DAYS_MAP[day_of_week]
1611+
days_until = (target_weekday - now.weekday() + 7) % 7
1612+
candidate = (now + timedelta(days=days_until)).replace(
1613+
hour=a_time.get("hour", 0),
1614+
minute=a_time.get("minute", 0),
1615+
second=a_time.get("second", 0),
1616+
microsecond=0,
1617+
)
1618+
if candidate <= now:
1619+
candidate += timedelta(days=7)
1620+
if next_activation is None or candidate < next_activation:
1621+
next_activation = candidate
1622+
1623+
if next_activation is None:
1624+
return None
1625+
1626+
local_tz = datetime.now().astimezone().tzinfo
1627+
return next_activation.replace(tzinfo=local_tz)
1628+
1629+
def get_ota_schedule_attrs(data, units):
1630+
oem_data = (FordpassDataHandler.get_states(data)
1631+
.get("commands", {})
1632+
.get("getASUSettingsCommand", {})
1633+
.get("value", {})
1634+
.get("oemData", {}))
1635+
if not oem_data:
1636+
return {}
1637+
1638+
weekly_schedule = {}
1639+
for entry_str in oem_data.get("OTAActivationDaySchedule", {}).get("stringArrayValue", []):
1640+
try:
1641+
entry = json.loads(entry_str)
1642+
except (ValueError, TypeError):
1643+
continue
1644+
day = entry.get("activationDayOfWeek", "").lower()
1645+
times = entry.get("activationScheduleTime", [])
1646+
if day and times:
1647+
weekly_schedule[day] = ", ".join(
1648+
f"{a_time.get('hour', 0):02d}:{a_time.get('minute', 0):02d}" for a_time in times
1649+
)
1650+
1651+
return {
1652+
"autoSoftwareUpdateEnabled": oem_data.get("ASUState", {}).get("stringValue"),
1653+
"scheduleType": oem_data.get("activationScheduleSetting", {}).get("stringValue"),
1654+
"weeklySchedule": weekly_schedule,
1655+
}
1656+
1657+
# the last successfully installed ECU/firmware package, sourced from the (non-custom) top-level
1658+
# events.configurationUpdateEvent
1659+
def get_last_firmware_update_state(data, prev_state=None):
1660+
config_update_event = FordpassDataHandler.get_events(data).get("configurationUpdateEvent", {})
1661+
update_time = config_update_event.get("updateTime")
1662+
if update_time is None:
1663+
return None
1664+
try:
1665+
return dt.as_local(dt.parse_datetime(update_time))
1666+
except BaseException as ex:
1667+
_LOGGER.debug(f"get_last_firmware_update_state(): could not parse datetime: '{update_time}', error: {ex}")
1668+
return None
1669+
1670+
def get_last_firmware_update_attrs(data, units):
1671+
config_update_event = FordpassDataHandler.get_events(data).get("configurationUpdateEvent", {})
1672+
if not config_update_event:
1673+
return {}
1674+
1675+
oem_data = config_update_event.get("oemData", {})
1676+
updated_ecus = []
1677+
for entry_str in oem_data.get("ecu_configuration", {}).get("stringArrayValue", []):
1678+
try:
1679+
ecu = json.loads(entry_str)
1680+
except (ValueError, TypeError):
1681+
continue
1682+
updated_ecus.append({
1683+
"ecuId": ecu.get("ECUId"),
1684+
"partNumber": ecu.get("partIIPartNumber"),
1685+
})
1686+
1687+
return {
1688+
"firmwareVersion": oem_data.get("ftcp_version", {}).get("stringValue"),
1689+
"updatedEcus": updated_ecus,
1690+
}
1691+
15441692
# DEPARTURE_SCHEDULE SENSOR & SERVICE stuff
15451693
def get_departure_schedules_state(data, prev_state=None):
15461694
ev_attrs = FordpassDataHandler.get_elveh_attrs(data, units=None)

custom_components/fordpass/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,6 @@
1212
"loggers": ["custom_components.fordpass"],
1313
"requirements": [],
1414
"ssdp": [],
15-
"version": "2026.7.0",
15+
"version": "2026.7.1",
1616
"zeroconf": []
1717
}

custom_components/fordpass/sensor.py

Lines changed: 85 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
"""All vehicle sensors from the accessible by the API"""
2+
import json
23
import logging
34
from dataclasses import replace
45
from datetime import datetime
@@ -10,13 +11,13 @@
1011
from homeassistant.const import Platform
1112
from homeassistant.core import HomeAssistant
1213
from homeassistant.helpers import entity_platform
13-
from homeassistant.helpers.restore_state import RestoreEntity, async_get, StoredState
14+
from homeassistant.helpers.restore_state import RestoreEntity, RestoredExtraData, async_get, StoredState
1415

1516
from . import FordPassEntity, FordPassDataUpdateCoordinator, ROOT_METRICS
1617
from .const import DOMAIN
1718
from .const_shared import COORDINATOR_KEY
1819
from .const_tags import SENSORS, ExtSensorEntityDescription, Tag
19-
from .fordpass_handler import UNSUPPORTED
20+
from .fordpass_handler import FordpassDataHandler, UNSUPPORTED
2021

2122
_LOGGER = logging.getLogger(__name__)
2223

@@ -38,22 +39,26 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, asyn
3839
_LOGGER.debug(f"{coordinator.vli}SENSOR '{a_entity_description.tag}' not supported for this engine-type/vehicle")
3940
continue
4041

41-
sensor = FordPassSensor(coordinator, a_entity_description)
42-
# # we want to restore the last known 'id' of the energyTransferLogs
43-
# if a_entity_description.tag == Tag.LAST_ENERGY_TRANSFER_LOG_ENTRY:
44-
# #restored_value = storage.last_states.get(sensor.entity_id, None)
45-
# restored_entity = storage.entities.get(sensor.entity_id, None)
46-
# if restored_entity is not None:
47-
# restored_extra_data = await restored_entity.async_get_last_extra_data()
48-
# if restored_extra_data is not None and "id" in restored_extra_data and restored_extra_data["id"] is not None:
49-
# coordinator._last_ENERGY_TRANSFER_LOG_ENTRY_ID = restored_extra_data["id"]
50-
#
51-
# # if restored_value is not None or restored_value != UNSUPPORTED:
52-
# # coordinator._last_ENERGY_TRANSFER_LOG_ENTRY_ID = restored_value
53-
# # _LOGGER.debug(f"{a_entity_description.tag} -> RESTORED value {restored_value}")
54-
# # else:
55-
# # coordinator._last_ENERGY_TRANSFER_LOG_ENTRY_ID = None
56-
# # _LOGGER.debug(f"{a_entity_description.tag} no VALUE to RESTORE {restored_value}")
42+
if a_entity_description.tag == Tag.FIRMWARE_UPDATE_HISTORY:
43+
# a special sensor implementation created by TheHangMan97
44+
sensor = FordPassFirmwareUpdateHistorySensor(coordinator, a_entity_description)
45+
else:
46+
sensor = FordPassSensor(coordinator, a_entity_description)
47+
# # we want to restore the last known 'id' of the energyTransferLogs
48+
# if a_entity_description.tag == Tag.LAST_ENERGY_TRANSFER_LOG_ENTRY:
49+
# #restored_value = storage.last_states.get(sensor.entity_id, None)
50+
# restored_entity = storage.entities.get(sensor.entity_id, None)
51+
# if restored_entity is not None:
52+
# restored_extra_data = await restored_entity.async_get_last_extra_data()
53+
# if restored_extra_data is not None and "id" in restored_extra_data and restored_extra_data["id"] is not None:
54+
# coordinator._last_ENERGY_TRANSFER_LOG_ENTRY_ID = restored_extra_data["id"]
55+
#
56+
# # if restored_value is not None or restored_value != UNSUPPORTED:
57+
# # coordinator._last_ENERGY_TRANSFER_LOG_ENTRY_ID = restored_value
58+
# # _LOGGER.debug(f"{a_entity_description.tag} -> RESTORED value {restored_value}")
59+
# # else:
60+
# # coordinator._last_ENERGY_TRANSFER_LOG_ENTRY_ID = None
61+
# # _LOGGER.debug(f"{a_entity_description.tag} no VALUE to RESTORE {restored_value}")
5762

5863
if a_entity_description.state_class == SensorStateClass.TOTAL_INCREASING:
5964
# make sure that the entity_id will have the correct domain!
@@ -128,4 +133,65 @@ def available(self):
128133
# the countdown sensor can be always active (does not hurt)
129134
# if self._tag == Tag.REMOTE_START_COUNTDOWN:
130135
# return state and Tag.REMOTE_START_STATUS.get_state(self.coordinator.data) == REMOTE_START_STATE_ACTIVE
131-
return state
136+
return state
137+
138+
139+
class FordPassFirmwareUpdateHistorySensor(FordPassSensor):
140+
"""Accumulates completed firmware/ECU updates across coordinator refreshes.
141+
142+
Ford does not expose a history list for this (unlike e.g. energyTransferLogs), so we build our
143+
own by watching events.configurationUpdateEvent for new updateTime values and persisting the
144+
resulting list across HA restarts via RestoreEntity.
145+
"""
146+
_MAX_HISTORY_ENTRIES = 20
147+
148+
def __init__(self, coordinator: FordPassDataUpdateCoordinator, entity_description: ExtSensorEntityDescription):
149+
self._history: list = []
150+
super().__init__(coordinator, entity_description)
151+
152+
async def async_added_to_hass(self):
153+
await super().async_added_to_hass()
154+
last_extra_data = await self.async_get_last_extra_data()
155+
if last_extra_data is not None:
156+
self._history = last_extra_data.as_dict().get("history", [])
157+
self._append_current_event_if_new()
158+
159+
@property
160+
def extra_restore_state_data(self):
161+
return RestoredExtraData({"history": self._history})
162+
163+
def _append_current_event_if_new(self):
164+
config_update_event = FordpassDataHandler.get_events(self.coordinator.data).get("configurationUpdateEvent", {})
165+
update_time = config_update_event.get("updateTime")
166+
if not update_time:
167+
return
168+
if self._history and self._history[-1].get("updateTime") == update_time:
169+
return
170+
171+
oem_data = config_update_event.get("oemData", {})
172+
updated_ecus = []
173+
for entry_str in oem_data.get("ecu_configuration", {}).get("stringArrayValue", []):
174+
try:
175+
ecu = json.loads(entry_str)
176+
except (ValueError, TypeError):
177+
continue
178+
updated_ecus.append({"ecuId": ecu.get("ECUId"), "partNumber": ecu.get("partIIPartNumber")})
179+
180+
self._history.append({
181+
"updateTime": update_time,
182+
"firmwareVersion": oem_data.get("ftcp_version", {}).get("stringValue"),
183+
"updatedEcus": updated_ecus,
184+
})
185+
self._history = self._history[-self._MAX_HISTORY_ENTRIES:]
186+
187+
def _handle_coordinator_update(self) -> None:
188+
self._append_current_event_if_new()
189+
super()._handle_coordinator_update()
190+
191+
@property
192+
def native_value(self):
193+
return len(self._history)
194+
195+
@property
196+
def extra_state_attributes(self):
197+
return {"history": list(reversed(self._history))}

custom_components/fordpass/translations/da.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,7 @@
328328
"engineoiltemp": {"name": "Temperatur: motorolie"},
329329
"deepsleep": {"name": "Dvaletilstand"},
330330
"firmwareupginprogress": {"name": "Firmware-opdatering i gang"},
331+
"firmwareupgstatus": {"name": "Firmware-opdatering Status"},
331332
"remotestartstatus": {"name": "RC (❄|☀): Status fjernstart"},
332333
"zonelighting": {"name": "Zonebelysning"},
333334
"messages": {"name": "Beskeder"},

0 commit comments

Comments
 (0)