Skip to content

Commit e6d344e

Browse files
SantaFoxclaude
andcommitted
0.1.1: diagnostic sensor "Last successful update"
Per-device timestamp entity (entity_category=DIAGNOSTIC, device_class=TIMESTAMP) showing when the coordinator last completed a successful poll. Lets users distinguish "polling alive but DSO has no fresh data" from "polling broken" without digging into logs. Coordinator now stamps EacData.last_success_time at the end of every successful refresh; the sensor reads it. One sensor per active service point (= per device). Bumps manifest.json version 0.1.0 → 0.1.1. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c353b10 commit e6d344e

5 files changed

Lines changed: 94 additions & 20 deletions

File tree

custom_components/eac_cyprus/coordinator.py

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

44
import logging
55
from dataclasses import dataclass
6-
from datetime import datetime, timedelta, timezone
6+
from datetime import UTC, datetime, timedelta
77

88
from homeassistant.config_entries import ConfigEntry
99
from homeassistant.core import HomeAssistant
@@ -45,6 +45,7 @@ class EacData:
4545
"""What the coordinator hands to entities."""
4646
user: UserDetails
4747
points: dict[str, ServicePointState] # keyed by service-point id
48+
last_success_time: datetime # tz-aware UTC; surfaced via a diagnostic sensor
4849

4950

5051
class EacCoordinator(DataUpdateCoordinator[EacData]):
@@ -69,7 +70,7 @@ async def _async_update_data(self) -> EacData:
6970
except EacApiError as err:
7071
raise UpdateFailed(f"API error: {err}") from err
7172

72-
end = datetime.now(timezone.utc)
73+
end = datetime.now(UTC)
7374
start = end - timedelta(days=HISTORY_DAYS)
7475

7576
points: dict[str, ServicePointState] = {}
@@ -113,7 +114,7 @@ async def _async_update_data(self) -> EacData:
113114
service_point=sp, active_meter=active_meter, channels=channels
114115
)
115116

116-
return EacData(user=user, points=points)
117+
return EacData(user=user, points=points, last_success_time=datetime.now(UTC))
117118

118119

119120
def _latest_reading(channels: list[ChannelReadings], channel_id: str) -> Reading | None:

custom_components/eac_cyprus/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,5 @@
99
"iot_class": "cloud_polling",
1010
"issue_tracker": "https://github.com/SantaFox/ha-eac/issues",
1111
"requirements": ["eac-dso-portal==0.1.0"],
12-
"version": "0.1.0"
12+
"version": "0.1.1"
1313
}

custom_components/eac_cyprus/sensor.py

Lines changed: 62 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
)
2727
from homeassistant.core import HomeAssistant, callback
2828
from homeassistant.helpers.device_registry import DeviceInfo
29+
from homeassistant.helpers.entity import EntityCategory
2930
from homeassistant.helpers.entity_platform import AddEntitiesCallback
3031
from homeassistant.helpers.update_coordinator import CoordinatorEntity
3132

@@ -122,25 +123,51 @@ async def async_setup_entry(
122123
"""Create sensors for all currently-known channels and watch for new ones."""
123124
coordinator: EacCoordinator = hass.data[DOMAIN][entry.entry_id]
124125

125-
known: set[tuple[str, str]] = set() # (service_point_id, channel_id)
126+
known_channels: set[tuple[str, str]] = set() # (service_point_id, channel_id)
127+
known_diagnostic: set[str] = set() # service_point_id
126128

127129
@callback
128130
def _add_new_entities() -> None:
129-
new_entities: list[EacChannelSensor] = []
131+
new_entities: list[CoordinatorEntity[EacCoordinator]] = []
130132
for sp_state in coordinator.data.points.values():
131133
for ch_state in sp_state.channels.values():
132134
key = (sp_state.service_point.id, ch_state.channel.id)
133-
if key in known:
135+
if key in known_channels:
134136
continue
135-
known.add(key)
136-
new_entities.append(EacChannelSensor(coordinator, sp_state, ch_state.channel.id))
137+
known_channels.add(key)
138+
new_entities.append(
139+
EacChannelSensor(coordinator, sp_state, ch_state.channel.id)
140+
)
141+
# One diagnostic sensor per device (= per active service point that
142+
# produced a meter config). Inactive points don't get a device, so
143+
# they don't get one either.
144+
if (
145+
sp_state.active_meter is not None
146+
and sp_state.service_point.id not in known_diagnostic
147+
):
148+
known_diagnostic.add(sp_state.service_point.id)
149+
new_entities.append(EacLastUpdateSensor(coordinator, sp_state))
137150
if new_entities:
138151
async_add_entities(new_entities)
139152

140153
_add_new_entities()
141154
entry.async_on_unload(coordinator.async_add_listener(_add_new_entities))
142155

143156

157+
def _device_info_for(sp_state: ServicePointState) -> DeviceInfo:
158+
meter = sp_state.active_meter
159+
return DeviceInfo(
160+
identifiers={(DOMAIN, sp_state.service_point.id)},
161+
name=sp_state.service_point.address or f"EAC {sp_state.service_point.id}",
162+
manufacturer=meter.manufacturer if meter else MANUFACTURER,
163+
model=meter.model if meter else None,
164+
serial_number=meter.serial_number if meter else sp_state.service_point.serial_number,
165+
configuration_url=(
166+
f"https://meterreading-dso.eac.com.cy/sp/{sp_state.service_point.id}"
167+
),
168+
)
169+
170+
144171
class EacChannelSensor(CoordinatorEntity[EacCoordinator], SensorEntity):
145172
"""One reading channel of one service point."""
146173

@@ -160,15 +187,7 @@ def __init__(
160187
self.entity_description = _description_for(ch.uom, ch.type, ch.interval)
161188
self._attr_unique_id = f"{self._sp_id}_{self._channel_id}"
162189

163-
meter = sp_state.active_meter
164-
self._attr_device_info = DeviceInfo(
165-
identifiers={(DOMAIN, self._sp_id)},
166-
name=sp_state.service_point.address or f"EAC {self._sp_id}",
167-
manufacturer=meter.manufacturer if meter else MANUFACTURER,
168-
model=meter.model if meter else None,
169-
serial_number=meter.serial_number if meter else sp_state.service_point.serial_number,
170-
configuration_url=f"https://meterreading-dso.eac.com.cy/sp/{self._sp_id}",
171-
)
190+
self._attr_device_info = _device_info_for(sp_state)
172191

173192
@property
174193
def _ch_state(self):
@@ -219,3 +238,32 @@ def extra_state_attributes(self) -> dict[str, Any]:
219238

220239
def _iso(dt: datetime | None) -> str | None:
221240
return dt.isoformat() if dt is not None else None
241+
242+
243+
class EacLastUpdateSensor(CoordinatorEntity[EacCoordinator], SensorEntity):
244+
"""Diagnostic timestamp showing when the coordinator last refreshed.
245+
246+
Lets users distinguish "polling is alive but the DSO has no fresh data"
247+
from "polling is broken" without digging into logs. Reports the same
248+
value across every device the integration owns — there is one polling
249+
cycle for the whole config entry.
250+
"""
251+
252+
_attr_has_entity_name = True
253+
_attr_entity_category = EntityCategory.DIAGNOSTIC
254+
_attr_device_class = SensorDeviceClass.TIMESTAMP
255+
_attr_name = "Last successful update"
256+
257+
def __init__(
258+
self, coordinator: EacCoordinator, sp_state: ServicePointState
259+
) -> None:
260+
super().__init__(coordinator)
261+
self._sp_id = sp_state.service_point.id
262+
self._attr_unique_id = f"{self._sp_id}_last_update"
263+
self._attr_device_info = _device_info_for(sp_state)
264+
265+
@property
266+
def native_value(self) -> datetime | None:
267+
if self.coordinator.data is None:
268+
return None
269+
return self.coordinator.data.last_success_time

tests/test_coordinator.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,19 @@ def _make_entry(hass: HomeAssistant) -> MockConfigEntry:
2626
async def test_coordinator_update_active_and_inactive_points(
2727
hass: HomeAssistant, mock_client
2828
) -> None:
29+
from datetime import UTC, datetime, timedelta
30+
2931
entry = _make_entry(hass)
3032
coord = EacCoordinator(hass, mock_client, entry)
3133
data = await coord._async_update_data()
3234

3335
assert data.user.name == "Test User"
3436
assert set(data.points) == {"111111111111", "222222222222"}
3537

38+
# last_success_time should be set to "now" on every successful refresh.
39+
assert data.last_success_time.tzinfo is not None
40+
assert datetime.now(UTC) - data.last_success_time < timedelta(seconds=10)
41+
3642
active = data.points["111111111111"]
3743
assert active.active_meter is not None
3844
assert active.active_meter.serial_number == "NEW123"

tests/test_sensor.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,9 @@ async def test_full_setup_creates_sensors(
3333
states = [
3434
s for s in hass.states.async_all() if s.entity_id.startswith("sensor.")
3535
]
36-
# One sensor per channel that returned data — 2 channels for the active SP.
37-
assert len(states) == 2
36+
# Two channel sensors + one diagnostic "Last successful update" for the
37+
# single active service point.
38+
assert len(states) == 3
3839

3940
# Check the cumulative total sensor.
4041
total = next(s for s in states if "total_24h" in s.entity_id)
@@ -46,6 +47,24 @@ async def test_full_setup_creates_sensors(
4647
assert "last_cumulative_reading" in total.attributes
4748

4849

50+
async def test_last_update_diagnostic_sensor(
51+
hass: HomeAssistant, patch_eac_client
52+
) -> None:
53+
"""Each active service point exposes a TIMESTAMP diagnostic entity."""
54+
from datetime import datetime
55+
56+
await _setup_integration(hass, patch_eac_client)
57+
state = next(
58+
s
59+
for s in hass.states.async_all()
60+
if s.entity_id.startswith("sensor.") and "last_successful_update" in s.entity_id
61+
)
62+
assert state.attributes["device_class"] == SensorDeviceClass.TIMESTAMP
63+
# The state is an ISO-8601 timestamp set by the coordinator on success.
64+
parsed = datetime.fromisoformat(state.state)
65+
assert parsed.tzinfo is not None
66+
67+
4968
async def test_30min_channel_is_power(
5069
hass: HomeAssistant, patch_eac_client
5170
) -> None:

0 commit comments

Comments
 (0)