Skip to content

Commit 10424a2

Browse files
Repair storage datetime (#4576)
1 parent b041625 commit 10424a2

8 files changed

Lines changed: 73 additions & 96 deletions

File tree

custom_components/battery_notes/binary_sensor.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,9 @@
5252
Template,
5353
TemplateStateFromEntityId,
5454
)
55+
from homeassistant.util import dt as dt_util
5556

56-
from .common import (
57-
utcnow_no_timezone,
58-
validate_is_float,
59-
)
57+
from .common import validate_is_float
6058
from .const import (
6159
ATTR_BATTERY_LAST_REPLACED,
6260
ATTR_BATTERY_LOW_THRESHOLD,
@@ -610,7 +608,7 @@ async def async_state_changed_listener(
610608
self.async_write_ha_state()
611609
return
612610

613-
self.coordinator.last_reported = utcnow_no_timezone()
611+
self.coordinator.last_reported = dt_util.utcnow()
614612
self.coordinator.battery_low_binary_state = (
615613
wrapped_battery_low_state.state == "on"
616614
)

custom_components/battery_notes/button.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@
1919
)
2020
from homeassistant.helpers.entity import EntityCategory
2121
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
22+
from homeassistant.util import dt as dt_util
2223

23-
from .common import utcnow_no_timezone
2424
from .const import (
2525
ATTR_BATTERY_QUANTITY,
2626
ATTR_BATTERY_TYPE,
@@ -146,7 +146,7 @@ async def async_added_to_hass(self) -> None:
146146

147147
async def async_press(self) -> None:
148148
"""Press the button."""
149-
self.coordinator.last_replaced = utcnow_no_timezone()
149+
self.coordinator.last_replaced = dt_util.utcnow()
150150

151151
self.hass.bus.async_fire(
152152
EVENT_BATTERY_REPLACED,
Lines changed: 0 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,6 @@
11
"""Common functions for battery_notes."""
22

3-
import re
4-
from datetime import datetime
5-
63
from homeassistant.helpers.device_registry import DeviceEntry
7-
from homeassistant.util import dt as dt_util
84

95

106
def validate_is_float(num):
@@ -18,35 +14,6 @@ def validate_is_float(num):
1814
return False
1915

2016

21-
def utcnow_no_timezone() -> datetime:
22-
"""Return UTC now without timezone information."""
23-
24-
return dt_util.utcnow().replace(tzinfo=None)
25-
26-
27-
def fix_datetime_string(datetime_str) -> str:
28-
"""Fix datetime string by replacing colon with period before microseconds."""
29-
# Prior to 3.3.2 there was an issue where microseconds were formatted with a colon and are held in storage.
30-
# New dates are stored correctly, over time the last_reported, last_replaced will be updated with the correct format.
31-
32-
# Look for timezone offset at the end (e.g., +00:00, -05:00, Z)
33-
tz_match = re.search(r"([+-]\d{2}:\d{2}|[+-]\d{4}|Z)$", datetime_str)
34-
35-
if tz_match:
36-
# Split into datetime and timezone parts
37-
tz_start = tz_match.start()
38-
datetime_part = datetime_str[:tz_start]
39-
tz_part = datetime_str[tz_start:]
40-
else:
41-
datetime_part = datetime_str
42-
tz_part = ""
43-
44-
# Replace colon with period only if followed by exactly 6 digits (microseconds)
45-
datetime_part = re.sub(r":(\d{6})$", r".\1", datetime_part)
46-
47-
return datetime_part + tz_part
48-
49-
5017
def get_device_model_id(device_entry: DeviceEntry) -> str | None:
5118
"""Get the device model if available."""
5219
return device_entry.model_id if hasattr(device_entry, "model_id") else None

custom_components/battery_notes/coordinator.py

Lines changed: 11 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,10 @@
2727
)
2828
from homeassistant.helpers.entity_registry import RegistryEntry
2929
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
30+
from homeassistant.util import dt as dt_util
3031
from homeassistant.util.hass_dict import HassKey
3132

32-
from .common import fix_datetime_string, utcnow_no_timezone, validate_is_float
33+
from .common import validate_is_float
3334
from .const import (
3435
ATTR_BATTERY_LAST_REPLACED,
3536
ATTR_BATTERY_LEVEL,
@@ -116,9 +117,7 @@ class BatteryNotesSubentryCoordinator(DataUpdateCoordinator[None]):
116117
wrapped_battery: RegistryEntry | None = None
117118
wrapped_battery_low: RegistryEntry | None = None
118119
is_orphaned: bool = False
119-
last_wrapped_battery_state_write: datetime = utcnow_no_timezone() - timedelta(
120-
hours=2
121-
)
120+
last_wrapped_battery_state_write: datetime = dt_util.utcnow() - timedelta(hours=2)
122121
_current_battery_level: str | None = None
123122
_previous_battery_low: bool | None = None
124123
_previous_battery_level: str | None = None
@@ -195,13 +194,11 @@ def __init__( # noqa: PLR0912
195194
device_entry = device_registry.async_get(self.device_id)
196195

197196
if device_entry and device_entry.created_at.year > 1970:
198-
last_replaced = device_entry.created_at.strftime(
199-
"%Y-%m-%dT%H:%M:%S.%f"
200-
)
197+
last_replaced = device_entry.created_at
201198
elif self.source_entity_id:
202199
entity = entity_registry.async_get(self.source_entity_id)
203200
if entity and entity.created_at.year > 1970:
204-
last_replaced = entity.created_at.strftime("%Y-%m-%dT%H:%M:%S.%f")
201+
last_replaced = entity.created_at
205202

206203
_LOGGER.debug(
207204
"Defaulting %s battery last replaced to %s",
@@ -210,11 +207,11 @@ def __init__( # noqa: PLR0912
210207
)
211208

212209
if last_replaced:
213-
self.last_replaced = datetime.fromisoformat(last_replaced)
210+
self.last_replaced = last_replaced
214211

215212
# If there is not a last_reported set to now
216213
if not self.last_reported:
217-
last_reported = utcnow_no_timezone()
214+
last_reported = dt_util.utcnow()
218215
_LOGGER.debug(
219216
"Defaulting %s battery last reported to %s",
220217
self.source_entity_id or self.device_id,
@@ -617,7 +614,7 @@ def current_battery_level(self, value):
617614
_LOGGER.debug("battery_increased event fired")
618615

619616
if self._current_battery_level not in [STATE_UNAVAILABLE, STATE_UNKNOWN]:
620-
self.last_reported = utcnow_no_timezone()
617+
self.last_reported = dt_util.utcnow()
621618
self.last_reported_level = cast(float, self._current_battery_level)
622619
self._previous_battery_low = self.battery_low
623620
self._previous_battery_level = self._current_battery_level
@@ -647,15 +644,7 @@ def last_replaced(self) -> datetime | None:
647644
)
648645

649646
if entry and LAST_REPLACED in entry and entry[LAST_REPLACED] is not None:
650-
entry_last_replaced = str(entry[LAST_REPLACED])
651-
if not entry_last_replaced.endswith("+00:00"):
652-
entry_last_replaced += "+00:00"
653-
654-
try:
655-
return datetime.fromisoformat(entry_last_replaced)
656-
except ValueError:
657-
entry_last_replaced = fix_datetime_string(entry_last_replaced)
658-
return datetime.fromisoformat(entry_last_replaced)
647+
return datetime.fromisoformat(str(entry[LAST_REPLACED]))
659648
return None
660649

661650
@last_replaced.setter
@@ -688,20 +677,12 @@ def last_reported(self) -> datetime | None:
688677
)
689678

690679
if entry and LAST_REPORTED in entry and entry[LAST_REPORTED] is not None:
691-
entry_last_reported = str(entry[LAST_REPORTED])
692-
if not entry_last_reported.endswith("+00:00"):
693-
entry_last_reported += "+00:00"
694-
695-
try:
696-
return datetime.fromisoformat(entry_last_reported)
697-
except ValueError:
698-
entry_last_reported = fix_datetime_string(entry_last_reported)
699-
return datetime.fromisoformat(entry_last_reported)
680+
return datetime.fromisoformat(str(entry[LAST_REPORTED]))
700681

701682
return None
702683

703684
@last_reported.setter
704-
def last_reported(self, value):
685+
def last_reported(self, value: datetime):
705686
"""Set the last reported datetime and store it."""
706687

707688
if not hasattr(self.config_entry, "runtime_data"):

custom_components/battery_notes/filters.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
from numbers import Number
88
from typing import cast
99

10-
from .common import utcnow_no_timezone
10+
from homeassistant.util import dt as dt_util
11+
1112
from .const import WINDOW_SIZE_UNIT_NUMBER_EVENTS, WINDOW_SIZE_UNIT_TIME
1213

1314
_LOGGER = logging.getLogger(__name__)
@@ -20,7 +21,7 @@ class FilterState:
2021

2122
def __init__(self, state: str | float | int) -> None:
2223
"""Initialize with HA State object."""
23-
self.timestamp = utcnow_no_timezone()
24+
self.timestamp = dt_util.utcnow()
2425
try:
2526
self.state = float(state)
2627
except ValueError:

custom_components/battery_notes/sensor.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,9 @@
5353
TemplateStateFromEntityId,
5454
)
5555
from homeassistant.helpers.typing import StateType
56+
from homeassistant.util import dt as dt_util
5657

57-
from .common import utcnow_no_timezone, validate_is_float
58+
from .common import validate_is_float
5859
from .const import (
5960
ATTR_BATTERY_LAST_REPLACED,
6061
ATTR_BATTERY_LAST_REPORTED,
@@ -560,20 +561,20 @@ async def async_state_reported_listener(
560561
return
561562

562563
# Don't update if battery level same and it's been < 1 hour
563-
delta = utcnow_no_timezone() - self.coordinator.last_wrapped_battery_state_write
564+
delta = dt_util.utcnow() - self.coordinator.last_wrapped_battery_state_write
564565
if (
565566
self.coordinator.last_reported_level == wrapped_battery_state.state
566567
and delta.total_seconds() < 3600 # 1 hour
567568
):
568569
self._attr_available = True
569570
return
570571

571-
self.coordinator.last_wrapped_battery_state_write = utcnow_no_timezone()
572+
self.coordinator.last_wrapped_battery_state_write = dt_util.utcnow()
572573
self.coordinator.current_battery_level = wrapped_battery_state.state
573574

574575
await self.coordinator.async_request_refresh()
575576

576-
self.coordinator.last_reported = utcnow_no_timezone()
577+
self.coordinator.last_reported = dt_util.utcnow()
577578

578579
_LOGGER.debug(
579580
"Entity id %s has been reported.",

custom_components/battery_notes/services.py

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
"""Define services for the Battery Notes integration."""
22

33
import logging
4-
from datetime import datetime
54
from typing import Any, cast
65

76
from homeassistant.core import (
@@ -15,7 +14,6 @@
1514
from homeassistant.helpers import device_registry as dr, entity_registry as er
1615
from homeassistant.util import dt as dt_util
1716

18-
from .common import utcnow_no_timezone
1917
from .const import (
2018
ATTR_BATTERY_LAST_REPLACED,
2119
ATTR_BATTERY_LAST_REPLACED_DAYS,
@@ -101,7 +99,7 @@ async def _async_battery_replaced(call: ServiceCall) -> ServiceResponse: # noqa
10199
if datetime_replaced_entry:
102100
datetime_replaced = dt_util.as_utc(datetime_replaced_entry).replace(tzinfo=None)
103101
else:
104-
datetime_replaced = utcnow_no_timezone()
102+
datetime_replaced = dt_util.utcnow()
105103

106104
entity_registry = er.async_get(call.hass)
107105
device_registry = dr.async_get(call.hass)
@@ -261,10 +259,7 @@ async def _async_battery_last_replaced(call: ServiceCall) -> ServiceResponse:
261259
):
262260
continue
263261

264-
time_since_last_replaced = (
265-
datetime.fromisoformat(str(utcnow_no_timezone()) + "+00:00")
266-
- coordinator.last_replaced
267-
)
262+
time_since_last_replaced = dt_util.utcnow() - coordinator.last_replaced
268263

269264
if time_since_last_replaced.days > days_last_replaced:
270265
if raise_events:
@@ -328,10 +323,7 @@ async def _async_battery_last_reported(call: ServiceCall) -> ServiceResponse:
328323
coordinator
329324
) in battery_notes_config_entry.runtime_data.subentry_coordinators.values():
330325
if coordinator.wrapped_battery and coordinator.last_reported:
331-
time_since_last_reported = (
332-
datetime.fromisoformat(str(utcnow_no_timezone()) + "+00:00")
333-
- coordinator.last_reported
334-
)
326+
time_since_last_reported = dt_util.utcnow() - coordinator.last_reported
335327
if time_since_last_reported.days > days_last_reported:
336328
if raise_events:
337329
call.hass.bus.async_fire(

custom_components/battery_notes/store.py

Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import logging
6+
import re
67
from collections import OrderedDict
78
from collections.abc import MutableMapping
89
from datetime import datetime
@@ -15,14 +16,16 @@
1516

1617
from .const import (
1718
DOMAIN,
19+
LAST_REPLACED,
20+
LAST_REPORTED,
1821
)
1922

2023
_LOGGER = logging.getLogger(__name__)
2124

2225
DATA_REGISTRY = f"{DOMAIN}_storage"
2326
STORAGE_KEY = f"{DOMAIN}.storage"
2427
STORAGE_VERSION_MAJOR = 1
25-
STORAGE_VERSION_MINOR = 0
28+
STORAGE_VERSION_MINOR = 2
2629
SAVE_DELAY = 10
2730

2831

@@ -48,21 +51,55 @@ class EntityEntry:
4851
battery_last_reported_level = attr.ib(type=float, default=None)
4952

5053

54+
def _fix_datetime_string(datetime_str: str) -> str:
55+
"""Fix datetime string by replacing colon with period before microseconds."""
56+
# Prior to 3.3.2 there was an issue where microseconds were formatted with a colon and are held in storage.
57+
58+
# Look for timezone offset at the end (e.g., +00:00, -05:00, Z)
59+
tz_match = re.search(r"([+-]\d{2}:\d{2}|[+-]\d{4}|Z)$", datetime_str)
60+
61+
if tz_match:
62+
# Split into datetime and timezone parts
63+
tz_start = tz_match.start()
64+
datetime_part = datetime_str[:tz_start]
65+
tz_part = datetime_str[tz_start:]
66+
else:
67+
datetime_part = datetime_str
68+
tz_part = "+00:00"
69+
70+
# Replace colon with period only if followed by exactly 6 digits (microseconds)
71+
datetime_part = re.sub(r":(\d{6})$", r".\1", datetime_part)
72+
73+
return datetime_part + tz_part
74+
75+
5176
class MigratableStore(Store):
5277
"""Holds battery notes data."""
5378

5479
async def _async_migrate_func(
5580
self,
56-
old_major_version: int, # noqa: ARG002
57-
old_minor_version: int, # noqa: ARG002
81+
old_major_version: int,
82+
old_minor_version: int,
5883
data: dict,
5984
):
60-
# pylint: disable=arguments-renamed
61-
# pylint: disable=unused-argument
62-
63-
# if old_major_version == 1:
64-
# Do nothing for now
65-
85+
if old_major_version == 1:
86+
if old_minor_version < 2:
87+
for device in data["devices"]:
88+
last_replaced = device[LAST_REPLACED]
89+
if last_replaced:
90+
device[LAST_REPLACED] = _fix_datetime_string(last_replaced)
91+
92+
last_reported = device[LAST_REPORTED]
93+
if last_reported:
94+
device[LAST_REPORTED] = _fix_datetime_string(last_reported)
95+
for entity in data["entities"]:
96+
last_replaced = entity[LAST_REPLACED]
97+
if last_replaced:
98+
entity[LAST_REPLACED] = _fix_datetime_string(last_replaced)
99+
100+
last_reported = entity[LAST_REPORTED]
101+
if last_reported:
102+
entity[LAST_REPORTED] = _fix_datetime_string(last_reported)
66103
return data
67104

68105

0 commit comments

Comments
 (0)