Skip to content

Commit 8f5fae9

Browse files
julienldclaude
andauthored
Fix: Handle missing last_changed timestamps in ha_get_history (#447) (#497)
* chore: Add local/ to gitignore Files in local/ directory should not be tracked by git. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix: handle missing last_changed timestamps in ha_get_history Fixes #447 where ha_get_history returned null last_changed and missing last_updated. Root cause: Home Assistant WebSocket API omits the 'lc' (last_changed) field when it equals 'lu' (last_updated) as an optimization. Our code didn't handle this case, resulting in null timestamps. Changes: - Updated timestamp extraction logic to use last_updated value when last_changed is missing - Added comprehensive unit tests for _convert_timestamp function - Added regression tests for the timestamp handling logic - Added E2E test to verify timestamps are present and valid in responses The fix ensures both last_changed and last_updated are always populated with valid ISO 8601 timestamps in the response. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix: use nested .get() calls to handle falsy values correctly Addresses Gemini Code Assist review feedback: - Changed `or` fallbacks to nested `.get()` calls to avoid bugs with falsy values - Empty dict `{}` for attributes is valid and should not fall back - Empty string `''` for state is valid and should not fall back The nested `.get(key1, .get(key2, default))` pattern only falls back when the key is missing, not when the value is falsy, which is the correct behavior. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 4d10ed9 commit 8f5fae9

3 files changed

Lines changed: 256 additions & 5 deletions

File tree

src/ha_mcp/tools/tools_history.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -327,18 +327,21 @@ async def ha_get_history(
327327
for state in limited_states:
328328
# Get timestamps - WebSocket returns short-form (lc/lu) as Unix epoch floats
329329
# or long-form (last_changed/last_updated) as strings
330-
last_changed_raw = state.get("lc", state.get("last_changed"))
330+
# Note: HA WebSocket API omits 'lc' when it equals 'lu' (optimization)
331331
last_updated_raw = state.get("lu", state.get("last_updated"))
332+
last_changed_raw = state.get("lc", state.get("last_changed"))
333+
334+
# If last_changed is missing, it means it equals last_updated
335+
if last_changed_raw is None and last_updated_raw is not None:
336+
last_changed_raw = last_updated_raw
332337

333338
state_entry = {
334339
"state": state.get("s", state.get("state")),
335340
"last_changed": _convert_timestamp(last_changed_raw),
336341
"last_updated": _convert_timestamp(last_updated_raw),
337342
}
338343
if not minimal_response:
339-
state_entry["attributes"] = state.get(
340-
"a", state.get("attributes", {})
341-
)
344+
state_entry["attributes"] = state.get("a", state.get("attributes", {}))
342345
formatted_states.append(state_entry)
343346

344347
entities_history.append(

tests/src/e2e/workflows/core/test_history.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,74 @@ async def test_get_history_entity_ids_as_comma_string(self, mcp_client):
270270
else:
271271
logger.info("Comma-separated format may not be supported")
272272

273+
async def test_get_history_timestamps_present(self, mcp_client):
274+
"""Test that history returns valid timestamps for last_changed and last_updated.
275+
276+
This is a regression test for issue #447 where timestamps were null/missing.
277+
"""
278+
logger.info("Testing ha_get_history includes valid timestamps")
279+
280+
result = await mcp_client.call_tool(
281+
"ha_get_history",
282+
{
283+
"entity_ids": "sun.sun",
284+
"start_time": "24h",
285+
"minimal_response": False,
286+
"significant_changes_only": False,
287+
"limit": 10,
288+
},
289+
)
290+
291+
data = assert_mcp_success(result, "Get history with timestamps")
292+
293+
# History data is nested in 'data' key
294+
inner_data = data.get("data", data)
295+
assert "entities" in inner_data, f"Missing 'entities' in response: {data}"
296+
assert len(inner_data["entities"]) > 0, "No entities in response"
297+
298+
entity_history = inner_data["entities"][0]
299+
assert "states" in entity_history, f"Missing states: {entity_history}"
300+
states = entity_history["states"]
301+
302+
if len(states) > 0:
303+
logger.info(f"Checking {len(states)} state entries for valid timestamps")
304+
305+
for idx, state in enumerate(states):
306+
# Verify both timestamp fields are present
307+
assert "last_changed" in state, f"State {idx} missing 'last_changed': {state}"
308+
assert "last_updated" in state, f"State {idx} missing 'last_updated': {state}"
309+
310+
# Verify timestamps are not null
311+
last_changed = state["last_changed"]
312+
last_updated = state["last_updated"]
313+
314+
assert last_changed is not None, f"State {idx} has null last_changed: {state}"
315+
assert last_updated is not None, f"State {idx} has null last_updated: {state}"
316+
317+
# Verify timestamps are valid ISO 8601 strings
318+
assert isinstance(last_changed, str), (
319+
f"State {idx} last_changed not a string: {type(last_changed)}"
320+
)
321+
assert isinstance(last_updated, str), (
322+
f"State {idx} last_updated not a string: {type(last_updated)}"
323+
)
324+
325+
# Verify timestamps can be parsed as ISO datetime
326+
try:
327+
datetime.fromisoformat(last_changed.replace("Z", "+00:00"))
328+
except ValueError as e:
329+
pytest.fail(f"State {idx} last_changed not valid ISO format: {last_changed}: {e}")
330+
331+
try:
332+
datetime.fromisoformat(last_updated.replace("Z", "+00:00"))
333+
except ValueError as e:
334+
pytest.fail(f"State {idx} last_updated not valid ISO format: {last_updated}: {e}")
335+
336+
logger.info("✓ All state entries have valid last_changed and last_updated timestamps")
337+
logger.info(f"Sample: last_changed={states[0]['last_changed']}, last_updated={states[0]['last_updated']}")
338+
else:
339+
logger.warning("No state history available for test (may be normal for short periods)")
340+
273341

274342
@pytest.mark.asyncio
275343
@pytest.mark.core

tests/src/unit/test_history_helpers.py

Lines changed: 181 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import pytest
66

7-
from ha_mcp.tools.tools_history import parse_relative_time
7+
from ha_mcp.tools.tools_history import _convert_timestamp, parse_relative_time
88

99

1010
class TestParseRelativeTime:
@@ -115,3 +115,183 @@ def test_large_hours_value(self):
115115
result = parse_relative_time("168h")
116116
expected = datetime.now(UTC) - timedelta(hours=168)
117117
assert abs((result - expected).total_seconds()) < 1
118+
119+
120+
class TestConvertTimestamp:
121+
"""Test _convert_timestamp function for issue #447 regression."""
122+
123+
def test_none_returns_none(self):
124+
"""None input returns None."""
125+
assert _convert_timestamp(None) is None
126+
127+
def test_unix_epoch_int_converted_to_iso(self):
128+
"""Unix epoch integer converted to ISO format string."""
129+
# 1700000000 = 2023-11-14T22:13:20+00:00 UTC
130+
timestamp = 1700000000
131+
result = _convert_timestamp(timestamp)
132+
assert result == "2023-11-14T22:13:20+00:00"
133+
134+
def test_unix_epoch_float_converted_to_iso(self):
135+
"""Unix epoch float with microseconds converted to ISO format."""
136+
# 1700000000.123456 = 2023-11-14T22:13:20.123456+00:00 UTC
137+
timestamp = 1700000000.123456
138+
result = _convert_timestamp(timestamp)
139+
# Should preserve microseconds
140+
assert result.startswith("2023-11-14T22:13:20.123456")
141+
assert result.endswith("+00:00")
142+
143+
def test_iso_string_passed_through(self):
144+
"""ISO format string passed through unchanged."""
145+
iso_string = "2026-01-17T12:00:00+00:00"
146+
result = _convert_timestamp(iso_string)
147+
assert result == iso_string
148+
149+
def test_iso_string_with_z_passed_through(self):
150+
"""ISO format string with Z suffix passed through."""
151+
iso_string = "2026-01-17T12:00:00Z"
152+
result = _convert_timestamp(iso_string)
153+
assert result == iso_string
154+
155+
def test_invalid_type_returns_none(self):
156+
"""Invalid type (not int/float/str/None) returns None."""
157+
assert _convert_timestamp([]) is None
158+
assert _convert_timestamp({}) is None
159+
assert _convert_timestamp(object()) is None
160+
161+
def test_zero_timestamp(self):
162+
"""Zero timestamp (epoch start) converts correctly."""
163+
result = _convert_timestamp(0)
164+
assert result == "1970-01-01T00:00:00+00:00"
165+
166+
def test_negative_timestamp(self):
167+
"""Negative timestamp (before epoch) converts correctly."""
168+
# 1969-12-31T23:00:00+00:00
169+
result = _convert_timestamp(-3600)
170+
assert result.startswith("1969-12-31T23:00:00")
171+
172+
173+
class TestTimestampHandling:
174+
"""Test timestamp handling logic for issue #447.
175+
176+
Issue #447: ha_get_history returned null last_changed and missing last_updated.
177+
Root cause: HA WebSocket API omits 'lc' when it equals 'lu' (optimization).
178+
Fix: When 'lc' is missing, use 'lu' value for both timestamps.
179+
"""
180+
181+
def test_both_timestamps_present(self):
182+
"""When both lc and lu present, use their respective values."""
183+
state = {
184+
"s": "on",
185+
"lc": 1700000000.0, # Different from lu
186+
"lu": 1700000100.0,
187+
"a": {},
188+
}
189+
190+
# Simulate the formatting logic from tools_history.py
191+
last_updated_raw = state.get("lu") or state.get("last_updated")
192+
last_changed_raw = state.get("lc") or state.get("last_changed")
193+
194+
if last_changed_raw is None and last_updated_raw is not None:
195+
last_changed_raw = last_updated_raw
196+
197+
last_changed = _convert_timestamp(last_changed_raw)
198+
last_updated = _convert_timestamp(last_updated_raw)
199+
200+
assert last_changed is not None
201+
assert last_updated is not None
202+
assert last_changed == "2023-11-14T22:13:20+00:00"
203+
assert last_updated == "2023-11-14T22:15:00+00:00"
204+
205+
def test_lc_omitted_when_equals_lu(self):
206+
"""When lc is omitted (equals lu), last_changed should use lu value.
207+
208+
This is the regression test for issue #447.
209+
HA WebSocket API omits 'lc' when state and timestamps are identical.
210+
"""
211+
state = {
212+
"s": "on",
213+
# 'lc' is omitted when it equals 'lu'
214+
"lu": 1700000000.0,
215+
"a": {},
216+
}
217+
218+
# Simulate the formatting logic from tools_history.py
219+
last_updated_raw = state.get("lu") or state.get("last_updated")
220+
last_changed_raw = state.get("lc") or state.get("last_changed")
221+
222+
# Critical fix: when lc is missing, use lu
223+
if last_changed_raw is None and last_updated_raw is not None:
224+
last_changed_raw = last_updated_raw
225+
226+
last_changed = _convert_timestamp(last_changed_raw)
227+
last_updated = _convert_timestamp(last_updated_raw)
228+
229+
# Both should have the same value
230+
assert last_changed is not None, "last_changed should not be None (issue #447)"
231+
assert last_updated is not None
232+
assert last_changed == "2023-11-14T22:13:20+00:00"
233+
assert last_updated == "2023-11-14T22:13:20+00:00"
234+
assert last_changed == last_updated
235+
236+
def test_long_form_timestamps(self):
237+
"""Test long-form timestamp keys (last_changed/last_updated) work."""
238+
state = {
239+
"state": "on",
240+
"last_changed": "2026-01-17T12:00:00+00:00",
241+
"last_updated": "2026-01-17T12:00:00+00:00",
242+
"attributes": {},
243+
}
244+
245+
last_updated_raw = state.get("lu") or state.get("last_updated")
246+
last_changed_raw = state.get("lc") or state.get("last_changed")
247+
248+
if last_changed_raw is None and last_updated_raw is not None:
249+
last_changed_raw = last_updated_raw
250+
251+
last_changed = _convert_timestamp(last_changed_raw)
252+
last_updated = _convert_timestamp(last_updated_raw)
253+
254+
assert last_changed == "2026-01-17T12:00:00+00:00"
255+
assert last_updated == "2026-01-17T12:00:00+00:00"
256+
257+
def test_long_form_lc_omitted(self):
258+
"""Test long-form with last_changed omitted."""
259+
state = {
260+
"state": "on",
261+
# 'last_changed' omitted
262+
"last_updated": "2026-01-17T12:00:00+00:00",
263+
"attributes": {},
264+
}
265+
266+
last_updated_raw = state.get("lu") or state.get("last_updated")
267+
last_changed_raw = state.get("lc") or state.get("last_changed")
268+
269+
if last_changed_raw is None and last_updated_raw is not None:
270+
last_changed_raw = last_updated_raw
271+
272+
last_changed = _convert_timestamp(last_changed_raw)
273+
last_updated = _convert_timestamp(last_updated_raw)
274+
275+
assert last_changed is not None
276+
assert last_updated is not None
277+
assert last_changed == last_updated
278+
279+
def test_no_timestamps_at_all(self):
280+
"""Test edge case where no timestamps present (shouldn't happen in practice)."""
281+
state = {
282+
"s": "on",
283+
"a": {},
284+
}
285+
286+
last_updated_raw = state.get("lu") or state.get("last_updated")
287+
last_changed_raw = state.get("lc") or state.get("last_changed")
288+
289+
if last_changed_raw is None and last_updated_raw is not None:
290+
last_changed_raw = last_updated_raw
291+
292+
last_changed = _convert_timestamp(last_changed_raw)
293+
last_updated = _convert_timestamp(last_updated_raw)
294+
295+
# Both should be None in this edge case
296+
assert last_changed is None
297+
assert last_updated is None

0 commit comments

Comments
 (0)