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
Some checks are pending
🐳 ⚠ docker-unsafe from next_release branch / docker_dev_unsafe (push) Waiting to run

Fixes #1587
This commit is contained in:
Jokob @NetAlertX
2026-04-04 23:04:58 +00:00
parent 0acc94ac28
commit 36e606e1a1
2 changed files with 53 additions and 25 deletions

View File

@@ -3,7 +3,6 @@
import json import json
import os import os
import sys import sys
from datetime import datetime
import time import time
import re import re
import paho.mqtt.client as mqtt 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, \ from helper import get_setting_value, bytes_to_string, \
sanitize_string, normalize_string # noqa: E402 [flake8 lint suppression] sanitize_string, normalize_string # noqa: E402 [flake8 lint suppression]
from database import DB, get_device_stats # 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] from models.notification_instance import NotificationInstance # noqa: E402 [flake8 lint suppression]
# Make sure the TIMEZONE for logging is correct # Make sure the TIMEZONE for logging is correct
@@ -504,8 +503,8 @@ def mqtt_start(db):
"vendor": sanitize_string(device["devVendor"]), "vendor": sanitize_string(device["devVendor"]),
"mac_address": str(device["devMac"]), "mac_address": str(device["devMac"]),
"model": devDisplayName, "model": devDisplayName,
"last_connection": prepTimeStamp(str(device["devLastConnection"])), "last_connection": format_date_iso(str(device["devLastConnection"])),
"first_connection": prepTimeStamp(str(device["devFirstConnection"])), "first_connection": format_date_iso(str(device["devFirstConnection"])),
"sync_node": device["devSyncHubNode"], "sync_node": device["devSyncHubNode"],
"group": device["devGroup"], "group": device["devGroup"],
"location": device["devLocation"], "location": device["devLocation"],
@@ -617,26 +616,6 @@ def to_binary_sensor(input):
return "OFF" 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--------------------- # -------------INIT---------------------
if __name__ == '__main__': if __name__ == '__main__':
sys.exit(main()) sys.exit(main())

View File

@@ -15,7 +15,7 @@ import pytest
INSTALL_PATH = os.getenv('NETALERTX_APP', '/app') INSTALL_PATH = os.getenv('NETALERTX_APP', '/app')
sys.path.extend([f"{INSTALL_PATH}/front/plugins", f"{INSTALL_PATH}/server"]) 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: class TestTimeNowUTC:
@@ -104,3 +104,52 @@ class TestTimeNowUTC:
t2 = datetime.datetime.strptime(t2_str, DATETIME_PATTERN) t2 = datetime.datetime.strptime(t2_str, DATETIME_PATTERN)
assert t2 >= t1 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)