diff --git a/front/plugins/_publisher_mqtt/mqtt.py b/front/plugins/_publisher_mqtt/mqtt.py index 7727ca4f..08ece315 100755 --- a/front/plugins/_publisher_mqtt/mqtt.py +++ b/front/plugins/_publisher_mqtt/mqtt.py @@ -3,7 +3,6 @@ import json import os import sys -from datetime import datetime import time import re import paho.mqtt.client as mqtt @@ -26,7 +25,7 @@ from logger import mylog, Logger # noqa: E402 [flake8 lint suppression] from helper import get_setting_value, bytes_to_string, \ sanitize_string, normalize_string # noqa: E402 [flake8 lint suppression] from database import DB, get_device_stats # noqa: E402 [flake8 lint suppression] -from utils.datetime_utils import timeNowUTC # noqa: E402 [flake8 lint suppression] +from utils.datetime_utils import timeNowUTC, format_date_iso # noqa: E402 [flake8 lint suppression] from models.notification_instance import NotificationInstance # noqa: E402 [flake8 lint suppression] # Make sure the TIMEZONE for logging is correct @@ -504,8 +503,8 @@ def mqtt_start(db): "vendor": sanitize_string(device["devVendor"]), "mac_address": str(device["devMac"]), "model": devDisplayName, - "last_connection": prepTimeStamp(str(device["devLastConnection"])), - "first_connection": prepTimeStamp(str(device["devFirstConnection"])), + "last_connection": format_date_iso(str(device["devLastConnection"])), + "first_connection": format_date_iso(str(device["devFirstConnection"])), "sync_node": device["devSyncHubNode"], "group": device["devGroup"], "location": device["devLocation"], @@ -617,26 +616,6 @@ def to_binary_sensor(input): return "OFF" -# ------------------------------------- -# Convert to format that is interpretable by Home Assistant -def prepTimeStamp(datetime_str): - try: - # Attempt to parse the input string to ensure it's a valid datetime - parsed_datetime = datetime.fromisoformat(datetime_str) - - # If the parsed datetime is naive (i.e., does not contain timezone info), add UTC timezone - if parsed_datetime.tzinfo is None: - parsed_datetime = conf.tz.localize(parsed_datetime) - - except ValueError: - mylog('verbose', [f"[{pluginName}] Timestamp conversion failed of string '{datetime_str}'"]) - # Use the current time if the input format is invalid - parsed_datetime = timeNowUTC(as_string=False) - - # Convert to the required format with 'T' between date and time and ensure the timezone is included - return parsed_datetime.isoformat() # This will include the timezone offset - - # -------------INIT--------------------- if __name__ == '__main__': sys.exit(main()) diff --git a/test/server/test_datetime_utils.py b/test/server/test_datetime_utils.py index 0161e765..afd0ccd5 100644 --- a/test/server/test_datetime_utils.py +++ b/test/server/test_datetime_utils.py @@ -15,7 +15,7 @@ import pytest INSTALL_PATH = os.getenv('NETALERTX_APP', '/app') sys.path.extend([f"{INSTALL_PATH}/front/plugins", f"{INSTALL_PATH}/server"]) -from utils.datetime_utils import timeNowUTC, DATETIME_PATTERN # noqa: E402 +from utils.datetime_utils import timeNowUTC, format_date_iso, DATETIME_PATTERN # noqa: E402 class TestTimeNowUTC: @@ -104,3 +104,52 @@ class TestTimeNowUTC: t2 = datetime.datetime.strptime(t2_str, DATETIME_PATTERN) assert t2 >= t1 + + +class TestFormatDateIso: + """ + Regression tests for format_date_iso(). + + Root cause being guarded: DB timestamps are stored as naive UTC strings + (e.g. '2026-04-04 08:54:00'). The old prepTimeStamp() called + conf.tz.localize() which LABELS the naive value with the local TZ offset + instead of CONVERTING it. This made '08:54 UTC' become '08:54+02:00', + telling Home Assistant the event happened at 06:54 UTC — 2 hours too early. + + format_date_iso() correctly replaces(tzinfo=UTC) first, then converts. + """ + + def test_naive_utc_string_gets_utc_tzinfo(self): + """A naive DB timestamp must be interpreted as UTC, not local time.""" + result = format_date_iso("2026-04-04 08:54:00") + assert result is not None + # Must contain a TZ offset ('+' or 'Z'), not be naive + assert "+" in result or result.endswith("Z"), \ + f"Expected timezone in ISO output, got: {result}" + + def test_naive_utc_string_offset_reflects_utc_source(self): + """ + The UTC instant must be preserved. Whatever the local offset, the + calendar moment encoded in the ISO string must equal 08:54 UTC. + """ + result = format_date_iso("2026-04-04 08:54:00") + parsed = datetime.datetime.fromisoformat(result) + # Normalise to UTC for the assertion + utc_parsed = parsed.astimezone(datetime.UTC) + assert utc_parsed.hour == 8 + assert utc_parsed.minute == 54 + + def test_empty_string_returns_none(self): + """format_date_iso('') must return None, not raise.""" + assert format_date_iso("") is None + + def test_none_returns_none(self): + """format_date_iso(None) must return None, not raise.""" + assert format_date_iso(None) is None + + def test_output_is_valid_iso8601(self): + """Output must be parseable by datetime.fromisoformat().""" + result = format_date_iso("2026-01-15 12:00:00") + assert result is not None + # Should not raise + datetime.datetime.fromisoformat(result)