Skip to content

Commit 36e606e

Browse files
committed
Refactor MQTT plugin: replace prepTimeStamp with format_date_iso for timestamp formatting and add regression tests for format_date_iso function Events have wrong time in HA
Fixes #1587
1 parent 0acc94a commit 36e606e

File tree

2 files changed

+53
-25
lines changed

2 files changed

+53
-25
lines changed

front/plugins/_publisher_mqtt/mqtt.py

Lines changed: 3 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import json
44
import os
55
import sys
6-
from datetime import datetime
76
import time
87
import re
98
import paho.mqtt.client as mqtt
@@ -26,7 +25,7 @@
2625
from helper import get_setting_value, bytes_to_string, \
2726
sanitize_string, normalize_string # noqa: E402 [flake8 lint suppression]
2827
from database import DB, get_device_stats # noqa: E402 [flake8 lint suppression]
29-
from utils.datetime_utils import timeNowUTC # noqa: E402 [flake8 lint suppression]
28+
from utils.datetime_utils import timeNowUTC, format_date_iso # noqa: E402 [flake8 lint suppression]
3029
from models.notification_instance import NotificationInstance # noqa: E402 [flake8 lint suppression]
3130

3231
# Make sure the TIMEZONE for logging is correct
@@ -504,8 +503,8 @@ def mqtt_start(db):
504503
"vendor": sanitize_string(device["devVendor"]),
505504
"mac_address": str(device["devMac"]),
506505
"model": devDisplayName,
507-
"last_connection": prepTimeStamp(str(device["devLastConnection"])),
508-
"first_connection": prepTimeStamp(str(device["devFirstConnection"])),
506+
"last_connection": format_date_iso(str(device["devLastConnection"])),
507+
"first_connection": format_date_iso(str(device["devFirstConnection"])),
509508
"sync_node": device["devSyncHubNode"],
510509
"group": device["devGroup"],
511510
"location": device["devLocation"],
@@ -617,26 +616,6 @@ def to_binary_sensor(input):
617616
return "OFF"
618617

619618

620-
# -------------------------------------
621-
# Convert to format that is interpretable by Home Assistant
622-
def prepTimeStamp(datetime_str):
623-
try:
624-
# Attempt to parse the input string to ensure it's a valid datetime
625-
parsed_datetime = datetime.fromisoformat(datetime_str)
626-
627-
# If the parsed datetime is naive (i.e., does not contain timezone info), add UTC timezone
628-
if parsed_datetime.tzinfo is None:
629-
parsed_datetime = conf.tz.localize(parsed_datetime)
630-
631-
except ValueError:
632-
mylog('verbose', [f"[{pluginName}] Timestamp conversion failed of string '{datetime_str}'"])
633-
# Use the current time if the input format is invalid
634-
parsed_datetime = timeNowUTC(as_string=False)
635-
636-
# Convert to the required format with 'T' between date and time and ensure the timezone is included
637-
return parsed_datetime.isoformat() # This will include the timezone offset
638-
639-
640619
# -------------INIT---------------------
641620
if __name__ == '__main__':
642621
sys.exit(main())

test/server/test_datetime_utils.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
INSTALL_PATH = os.getenv('NETALERTX_APP', '/app')
1616
sys.path.extend([f"{INSTALL_PATH}/front/plugins", f"{INSTALL_PATH}/server"])
1717

18-
from utils.datetime_utils import timeNowUTC, DATETIME_PATTERN # noqa: E402
18+
from utils.datetime_utils import timeNowUTC, format_date_iso, DATETIME_PATTERN # noqa: E402
1919

2020

2121
class TestTimeNowUTC:
@@ -104,3 +104,52 @@ def test_timeNowUTC_multiple_calls_increase(self):
104104
t2 = datetime.datetime.strptime(t2_str, DATETIME_PATTERN)
105105

106106
assert t2 >= t1
107+
108+
109+
class TestFormatDateIso:
110+
"""
111+
Regression tests for format_date_iso().
112+
113+
Root cause being guarded: DB timestamps are stored as naive UTC strings
114+
(e.g. '2026-04-04 08:54:00'). The old prepTimeStamp() called
115+
conf.tz.localize() which LABELS the naive value with the local TZ offset
116+
instead of CONVERTING it. This made '08:54 UTC' become '08:54+02:00',
117+
telling Home Assistant the event happened at 06:54 UTC — 2 hours too early.
118+
119+
format_date_iso() correctly replaces(tzinfo=UTC) first, then converts.
120+
"""
121+
122+
def test_naive_utc_string_gets_utc_tzinfo(self):
123+
"""A naive DB timestamp must be interpreted as UTC, not local time."""
124+
result = format_date_iso("2026-04-04 08:54:00")
125+
assert result is not None
126+
# Must contain a TZ offset ('+' or 'Z'), not be naive
127+
assert "+" in result or result.endswith("Z"), \
128+
f"Expected timezone in ISO output, got: {result}"
129+
130+
def test_naive_utc_string_offset_reflects_utc_source(self):
131+
"""
132+
The UTC instant must be preserved. Whatever the local offset, the
133+
calendar moment encoded in the ISO string must equal 08:54 UTC.
134+
"""
135+
result = format_date_iso("2026-04-04 08:54:00")
136+
parsed = datetime.datetime.fromisoformat(result)
137+
# Normalise to UTC for the assertion
138+
utc_parsed = parsed.astimezone(datetime.UTC)
139+
assert utc_parsed.hour == 8
140+
assert utc_parsed.minute == 54
141+
142+
def test_empty_string_returns_none(self):
143+
"""format_date_iso('') must return None, not raise."""
144+
assert format_date_iso("") is None
145+
146+
def test_none_returns_none(self):
147+
"""format_date_iso(None) must return None, not raise."""
148+
assert format_date_iso(None) is None
149+
150+
def test_output_is_valid_iso8601(self):
151+
"""Output must be parseable by datetime.fromisoformat()."""
152+
result = format_date_iso("2026-01-15 12:00:00")
153+
assert result is not None
154+
# Should not raise
155+
datetime.datetime.fromisoformat(result)

0 commit comments

Comments
 (0)