Skip to content

Commit 301dad4

Browse files
authored
Merge pull request #98 from wbyoung/add-last-webhook-received-sensor
Add a sensor for the when webhooks are received
2 parents 38326ef + 5dbf58d commit 301dad4

File tree

13 files changed

+837
-4
lines changed

13 files changed

+837
-4
lines changed

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,10 @@ All entities have the following attributes:
222222

223223
Links to relevant API documentation are provided for each entity described below as well as the [permissions each entity requires](https://smartcar.com/docs/api-reference/permissions). When the required permissions are [not requested during setup](#authorization-data-entry), those entities will not be created.
224224

225+
In addition to the above, there is a sensor to aid in setting up the integration:
226+
227+
- [`sensor.<make_model>_last_webhook_received`](#sensormake_model_last_webhook_received)
228+
225229
### `device_tracker.<make_model>_location`
226230

227231
The GPS [location](https://smartcar.com/docs/api-reference/get-location) of the vehicle.
@@ -624,6 +628,17 @@ Requires permissions: `read_security`, `control_security`
624628

625629
_Note: some models, e.g., VW ID.4 2023+ do not have this functionality._
626630

631+
### `sensor.<make_model>_last_webhook_received`
632+
633+
The time the last webhook was received. This is set even when a received webhook is not valid (i.e. an unauthenticated request or for an unknown vehicle).
634+
635+
Enabled by default: :white_check_mark:
636+
637+
#### Attributes
638+
639+
- `response_status`: The status code used to respond to the webhook.
640+
- `response_data`: The data sent in the response (when available).
641+
627642
## Actions
628643

629644
Smartcar provides the following actions:

custom_components/smartcar/__init__.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
async_get_config_entry_implementation,
1818
)
1919
from homeassistant.helpers.typing import ConfigType
20+
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
2021

2122
from . import util
2223
from .auth import AbstractAuth
@@ -59,7 +60,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
5960
oauth_session = OAuth2Session(hass, entry, implementation)
6061
auth = AsyncConfigEntryAuth(websession, oauth_session, API_HOST)
6162
coordinators: dict[str, SmartcarVehicleCoordinator] = {}
62-
entry.runtime_data = SmartcarData(auth=auth, coordinators=coordinators)
63+
meta_coordinator = DataUpdateCoordinator(
64+
hass, _LOGGER, name=f"{DOMAIN}_meta", config_entry=entry
65+
)
66+
meta_coordinator.async_set_updated_data({})
67+
entry.runtime_data = SmartcarData(
68+
auth=auth,
69+
coordinators=coordinators,
70+
meta_coordinator=meta_coordinator,
71+
)
6372
device_registry = dr.async_get(hass)
6473
other_vins = vehicle_vins_in_use(hass, entry)
6574

custom_components/smartcar/const.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ class EntityDescriptionKey(StrEnum):
113113
CHARGE_AMPERAGE_MAX = auto()
114114
CHARGE_FAST_CHARGER_PRESENT = auto()
115115
FIRMWARE_VERSION = auto()
116+
LAST_WEBHOOK_RECEIVED = auto()
116117

117118

118119
DEFAULT_ENABLED_ENTITY_DESCRIPTION_KEYS = {

custom_components/smartcar/coordinator.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -583,6 +583,8 @@ def _batch_add_defaults(self) -> None:
583583

584584
for entity in entities:
585585
_, key = entity.unique_id.split("_", 1)
586+
if key not in DATAPOINT_ENTITY_KEY_MAP:
587+
continue
586588
config = DATAPOINT_ENTITY_KEY_MAP[key]
587589

588590
# currently polling is only supported via the v2 api

custom_components/smartcar/entity.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,13 @@ class SmartcarEntityDescription(EntityDescription):
226226
)
227227

228228

229+
class SmartcarMetaEntityDescription(EntityDescription):
230+
"""Class describing Smartcar meta sensor entities."""
231+
232+
value_fn: Callable[[dict[str, Any]], str | int | float | dt.datetime | None]
233+
attr_fn: Callable[[dict[str, Any]], dict[str, Any]] = lambda _: {}
234+
235+
229236
def inject_raw_value[RawValueT](
230237
coordinator: SmartcarVehicleCoordinator,
231238
description: EntityDescription,

custom_components/smartcar/sensor.py

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
from dataclasses import dataclass
2+
import datetime as dt
23
from datetime import date, datetime
34
from decimal import Decimal
45
import logging
6+
from typing import Any
57

68
from homeassistant.components.sensor import (
79
SensorDeviceClass,
@@ -12,6 +14,7 @@
1214
from homeassistant.config_entries import ConfigEntry
1315
from homeassistant.const import (
1416
PERCENTAGE,
17+
EntityCategory,
1518
UnitOfElectricCurrent,
1619
UnitOfElectricPotential,
1720
UnitOfEnergy,
@@ -23,19 +26,28 @@
2326
UnitOfVolume,
2427
)
2528
from homeassistant.core import HomeAssistant
29+
from homeassistant.helpers.device_registry import DeviceInfo
2630
from homeassistant.helpers.entity_platform import AddEntitiesCallback
2731
from homeassistant.helpers.typing import StateType
32+
from homeassistant.helpers.update_coordinator import (
33+
CoordinatorEntity,
34+
DataUpdateCoordinator,
35+
)
2836
from homeassistant.util.unit_conversion import DistanceConverter, PressureConverter
2937

30-
from .const import EntityDescriptionKey
38+
from .const import DOMAIN, EntityDescriptionKey
3139
from .coordinator import (
3240
VEHICLE_BACK_ROW,
3341
VEHICLE_FRONT_ROW,
3442
VEHICLE_LEFT_COLUMN,
3543
VEHICLE_RIGHT_COLUMN,
3644
SmartcarVehicleCoordinator,
3745
)
38-
from .entity import SmartcarEntity, SmartcarEntityDescription
46+
from .entity import (
47+
SmartcarEntity,
48+
SmartcarEntityDescription,
49+
SmartcarMetaEntityDescription,
50+
)
3951

4052
_LOGGER = logging.getLogger(__name__)
4153

@@ -45,6 +57,13 @@ class SmartcarSensorDescription(SensorEntityDescription, SmartcarEntityDescripti
4557
"""Class describing Smartcar sensor entities."""
4658

4759

60+
@dataclass(frozen=True, kw_only=True)
61+
class SmartcarMetaSensorDescription(
62+
SensorEntityDescription, SmartcarMetaEntityDescription
63+
):
64+
"""Class describing Smartcar meta sensor entities."""
65+
66+
4867
SENSOR_TYPES: tuple[SmartcarSensorDescription, ...] = (
4968
SmartcarSensorDescription(
5069
key=EntityDescriptionKey.BATTERY_CAPACITY,
@@ -310,6 +329,23 @@ class SmartcarSensorDescription(SensorEntityDescription, SmartcarEntityDescripti
310329
),
311330
)
312331

332+
META_SENSOR_TYPES: tuple[SmartcarMetaSensorDescription, ...] = (
333+
SmartcarMetaSensorDescription(
334+
key=EntityDescriptionKey.LAST_WEBHOOK_RECEIVED,
335+
name="Last Webhook Received",
336+
device_class=SensorDeviceClass.TIMESTAMP,
337+
entity_category=EntityCategory.DIAGNOSTIC,
338+
value_fn=lambda data: data.get("last_webhook_received_at"),
339+
attr_fn=lambda data: (
340+
{
341+
f"response_{key}": value
342+
for key, value in data.get("last_webhook_response", {}).items()
343+
}
344+
),
345+
icon="mdi:clock",
346+
),
347+
)
348+
313349

314350
async def async_setup_entry( # noqa: RUF029
315351
hass: HomeAssistant, # noqa: ARG001
@@ -320,12 +356,21 @@ async def async_setup_entry( # noqa: RUF029
320356
coordinators: dict[str, SmartcarVehicleCoordinator] = (
321357
entry.runtime_data.coordinators
322358
)
359+
meta_coordinator = entry.runtime_data.meta_coordinator
323360
_LOGGER.debug("Setting up sensors for VINs: %s", list(coordinators.keys()))
324361
entities = [
325362
SmartcarSensor(coordinator, description)
326363
for coordinator in coordinators.values()
327364
for description in SENSOR_TYPES
328365
if coordinator.is_scope_enabled(description.key, verbose=True)
366+
] + [
367+
SmartcarMetaSensor(
368+
meta_coordinator,
369+
description,
370+
{"identifiers": {(DOMAIN, vehicle_coordinator.vin)}},
371+
)
372+
for vehicle_coordinator in coordinators.values()
373+
for description in META_SENSOR_TYPES
329374
]
330375
_LOGGER.info("Adding %s Smartcar sensor entities", len(entities))
331376
async_add_entities(entities)
@@ -341,3 +386,35 @@ class SmartcarSensor[ValueT, RawValueT](
341386
@property
342387
def native_value(self) -> StateType | date | datetime | Decimal:
343388
return self._extract_value()
389+
390+
391+
class SmartcarMetaSensor(CoordinatorEntity[DataUpdateCoordinator], SensorEntity):
392+
"""Meta sensor entity."""
393+
394+
_attr_has_entity_name = True
395+
entity_description: SmartcarMetaEntityDescription
396+
397+
def __init__(
398+
self,
399+
coordinator: DataUpdateCoordinator,
400+
description: SmartcarMetaEntityDescription,
401+
device_info: DeviceInfo,
402+
) -> None:
403+
"""Initialize."""
404+
super().__init__(coordinator)
405+
406+
(_, vin) = next(iter(device_info["identifiers"]))
407+
408+
self.entity_description = description
409+
self._attr_unique_id = f"{vin}_{description.key}"
410+
self._attr_device_info = device_info
411+
412+
@property
413+
def native_value(self) -> str | int | float | dt.datetime | None:
414+
"""Return the state."""
415+
return self.entity_description.value_fn(self.coordinator.data)
416+
417+
@property
418+
def extra_state_attributes(self) -> dict[str, Any]:
419+
"""Return the state attributes."""
420+
return self.entity_description.attr_fn(self.coordinator.data)

custom_components/smartcar/types.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
from dataclasses import dataclass
66
from typing import TYPE_CHECKING
77

8+
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
9+
810
from .auth import AbstractAuth
911

1012
if TYPE_CHECKING:
@@ -17,6 +19,7 @@ class SmartcarData:
1719

1820
auth: AbstractAuth
1921
coordinators: dict[str, SmartcarVehicleCoordinator]
22+
meta_coordinator: DataUpdateCoordinator
2023

2124

2225
@dataclass

custom_components/smartcar/webhooks.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
from collections.abc import Callable
12
import copy
3+
from functools import wraps
24
import hmac
35
from http import HTTPStatus
46
import json
57
import logging
6-
from typing import Any, Literal
8+
from typing import Any, Literal, cast
79

810
from aiohttp import web
911
from homeassistant.components import cloud, webhook
@@ -39,6 +41,30 @@ async def webhook_url_from_id(hass: HomeAssistant, webhook_id: str) -> tuple[str
3941
return webhook_url, cloudhook
4042

4143

44+
def update_meta_coordinator_data[F: Callable[..., Any], ReturnT](fn: F) -> F:
45+
@wraps(fn)
46+
async def wrapper(*args, **kwargs) -> ReturnT: # noqa: ANN002, ANN003
47+
response = await fn(*args, **kwargs)
48+
config_entry = kwargs["config_entry"]
49+
status = response.status
50+
data = response.text or (response.body and response.body.decode("utf-8"))
51+
meta_coordinator = config_entry.runtime_data.meta_coordinator
52+
meta_coordinator.async_set_updated_data(
53+
{
54+
**meta_coordinator.data,
55+
"last_webhook_received_at": dt_util.utcnow(),
56+
"last_webhook_response": {
57+
"status": status,
58+
**({"data": data} if data else {}),
59+
},
60+
}
61+
)
62+
return cast("ReturnT", response)
63+
64+
return cast("F", wrapper)
65+
66+
67+
@update_meta_coordinator_data
4268
async def handle_webhook(
4369
hass: HomeAssistant, # noqa: ARG001
4470
webhook_id: str, # noqa: ARG001

tests/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import importlib
44

5+
import datetime as dt
56
from homeassistant.core import HomeAssistant
67
from pytest_homeassistant_custom_component.common import (
78
MockConfigEntry,
@@ -12,6 +13,7 @@
1213
from custom_components.smartcar.const import DOMAIN
1314

1415
MOCK_API_ENDPOINT = "http://test.local"
16+
MOCK_UTC_NOW = dt.datetime(2026, 2, 17, 16, 21, 32, 3842, tzinfo=dt.UTC)
1517

1618

1719
async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None:

tests/conftest.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
from . import bootstrap as bootstrap # noqa: I001, PLC0414
44

5+
from freezegun.api import FrozenDateTimeFactory
6+
57
from collections.abc import Awaitable, Callable, Generator
68
import json
79
import logging
@@ -31,6 +33,7 @@
3133
from pytest_homeassistant_custom_component.typing import ClientSessionGenerator
3234
from syrupy.assertion import SnapshotAssertion
3335
from .syrupy import SmartcarSnapshotExtension
36+
from . import MOCK_UTC_NOW
3437

3538
from custom_components.smartcar.auth import AbstractAuth
3639
from custom_components.smartcar.const import (
@@ -94,6 +97,15 @@ def snapshot(snapshot: SnapshotAssertion) -> SnapshotAssertion:
9497
return snapshot.use_extension(SmartcarSnapshotExtension)
9598

9699

100+
@pytest.fixture
101+
def mock_now(
102+
hass: HomeAssistant,
103+
freezer: FrozenDateTimeFactory,
104+
):
105+
"""Return a mock now & utcnow datetime."""
106+
freezer.move_to(MOCK_UTC_NOW)
107+
108+
97109
@pytest.fixture
98110
def mock_hmac_sha256_hexdigest_value() -> str:
99111
return "1234"
@@ -346,6 +358,7 @@ def webhook_scenario(
346358
webhook_body: str,
347359
webhook_headers: dict[str, Any],
348360
expected: dict[str, Any],
361+
mock_now: Any,
349362
device_registry: dr.DeviceRegistry,
350363
entity_registry: er.EntityRegistry,
351364
caplog: pytest.LogCaptureFixture,

0 commit comments

Comments
 (0)