mirror of
https://github.com/esphome/esphome.git
synced 2024-12-22 13:34:54 +01:00
Convert time to use tzdata (#2425)
This commit is contained in:
parent
eaa5200a35
commit
912793eddf
2 changed files with 49 additions and 115 deletions
|
@ -1,10 +1,8 @@
|
||||||
import bisect
|
|
||||||
import datetime
|
|
||||||
import logging
|
import logging
|
||||||
import math
|
from importlib import resources
|
||||||
import string
|
from typing import Optional
|
||||||
|
from datetime import timezone
|
||||||
|
|
||||||
import pytz
|
|
||||||
import tzlocal
|
import tzlocal
|
||||||
|
|
||||||
import esphome.codegen as cg
|
import esphome.codegen as cg
|
||||||
|
@ -44,111 +42,47 @@ ESPTime = time_ns.struct("ESPTime")
|
||||||
TimeHasTimeCondition = time_ns.class_("TimeHasTimeCondition", Condition)
|
TimeHasTimeCondition = time_ns.class_("TimeHasTimeCondition", Condition)
|
||||||
|
|
||||||
|
|
||||||
def _tz_timedelta(td):
|
def _load_tzdata(iana_key: str) -> Optional[bytes]:
|
||||||
offset_hour = int(td.total_seconds() / (60 * 60))
|
# From https://tzdata.readthedocs.io/en/latest/#examples
|
||||||
offset_minute = int(abs(td.total_seconds() / 60)) % 60
|
|
||||||
offset_second = int(abs(td.total_seconds())) % 60
|
|
||||||
if offset_hour == 0 and offset_minute == 0 and offset_second == 0:
|
|
||||||
return "0"
|
|
||||||
if offset_minute == 0 and offset_second == 0:
|
|
||||||
return f"{offset_hour}"
|
|
||||||
if offset_second == 0:
|
|
||||||
return f"{offset_hour}:{offset_minute}"
|
|
||||||
return f"{offset_hour}:{offset_minute}:{offset_second}"
|
|
||||||
|
|
||||||
|
|
||||||
# https://stackoverflow.com/a/16804556/8924614
|
|
||||||
def _week_of_month(dt):
|
|
||||||
first_day = dt.replace(day=1)
|
|
||||||
dom = dt.day
|
|
||||||
adjusted_dom = dom + first_day.weekday()
|
|
||||||
return int(math.ceil(adjusted_dom / 7.0))
|
|
||||||
|
|
||||||
|
|
||||||
def _tz_dst_str(dt):
|
|
||||||
td = datetime.timedelta(hours=dt.hour, minutes=dt.minute, seconds=dt.second)
|
|
||||||
return f"M{dt.month}.{_week_of_month(dt)}.{dt.isoweekday() % 7}/{_tz_timedelta(td)}"
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_tzname(tz, dt):
|
|
||||||
tzname = tz.tzname(dt)
|
|
||||||
# pytz does not always return valid tznames
|
|
||||||
# For example: 'Europe/Saratov' returns '+04'
|
|
||||||
# Work around it by using a generic name for the timezone
|
|
||||||
if not all(c in string.ascii_letters for c in tzname):
|
|
||||||
return "TZ"
|
|
||||||
return tzname
|
|
||||||
|
|
||||||
|
|
||||||
def _non_dst_tz(tz, dt):
|
|
||||||
tzname = _safe_tzname(tz, dt)
|
|
||||||
utcoffset = tz.utcoffset(dt)
|
|
||||||
_LOGGER.info(
|
|
||||||
"Detected timezone '%s' with UTC offset %s", tzname, _tz_timedelta(utcoffset)
|
|
||||||
)
|
|
||||||
tzbase = f"{tzname}{_tz_timedelta(-1 * utcoffset)}"
|
|
||||||
return tzbase
|
|
||||||
|
|
||||||
|
|
||||||
def convert_tz(pytz_obj):
|
|
||||||
tz = pytz_obj
|
|
||||||
|
|
||||||
now = datetime.datetime.now()
|
|
||||||
first_january = datetime.datetime(year=now.year, month=1, day=1)
|
|
||||||
|
|
||||||
if not isinstance(tz, pytz.tzinfo.DstTzInfo):
|
|
||||||
return _non_dst_tz(tz, first_january)
|
|
||||||
|
|
||||||
# pylint: disable=protected-access
|
|
||||||
transition_times = tz._utc_transition_times
|
|
||||||
transition_info = tz._transition_info
|
|
||||||
idx = max(0, bisect.bisect_right(transition_times, now))
|
|
||||||
if idx >= len(transition_times):
|
|
||||||
return _non_dst_tz(tz, now)
|
|
||||||
|
|
||||||
idx1, idx2 = idx, idx + 1
|
|
||||||
dstoffset1 = transition_info[idx1][1]
|
|
||||||
if dstoffset1 == datetime.timedelta(seconds=0):
|
|
||||||
# Normalize to 1 being DST on
|
|
||||||
idx1, idx2 = idx + 1, idx + 2
|
|
||||||
|
|
||||||
if idx2 >= len(transition_times):
|
|
||||||
return _non_dst_tz(tz, now)
|
|
||||||
|
|
||||||
if transition_times[idx2].year > now.year + 1:
|
|
||||||
# Next transition is scheduled after this year
|
|
||||||
# Probably a scheduler timezone change.
|
|
||||||
return _non_dst_tz(tz, now)
|
|
||||||
|
|
||||||
utcoffset_on, _, tzname_on = transition_info[idx1]
|
|
||||||
utcoffset_off, _, tzname_off = transition_info[idx2]
|
|
||||||
dst_begins_utc = transition_times[idx1]
|
|
||||||
dst_begins_local = dst_begins_utc + utcoffset_off
|
|
||||||
dst_ends_utc = transition_times[idx2]
|
|
||||||
dst_ends_local = dst_ends_utc + utcoffset_on
|
|
||||||
|
|
||||||
tzbase = f"{tzname_off}{_tz_timedelta(-1 * utcoffset_off)}"
|
|
||||||
|
|
||||||
tzext = f"{tzname_on}{_tz_timedelta(-1 * utcoffset_on)},{_tz_dst_str(dst_begins_local)},{_tz_dst_str(dst_ends_local)}"
|
|
||||||
_LOGGER.info(
|
|
||||||
"Detected timezone '%s' with UTC offset %s and daylight saving time from "
|
|
||||||
"%s to %s",
|
|
||||||
tzname_off,
|
|
||||||
_tz_timedelta(utcoffset_off),
|
|
||||||
dst_begins_local.strftime("%d %B %X"),
|
|
||||||
dst_ends_local.strftime("%d %B %X"),
|
|
||||||
)
|
|
||||||
return tzbase + tzext
|
|
||||||
|
|
||||||
|
|
||||||
def detect_tz():
|
|
||||||
try:
|
try:
|
||||||
tz = tzlocal.get_localzone()
|
package_loc, resource = iana_key.rsplit("/", 1)
|
||||||
except pytz.exceptions.UnknownTimeZoneError:
|
except ValueError:
|
||||||
_LOGGER.warning("Could not auto-detect timezone. Using UTC...")
|
return None
|
||||||
return "UTC"
|
package = "tzdata.zoneinfo." + package_loc.replace("/", ".")
|
||||||
|
|
||||||
return convert_tz(tz)
|
try:
|
||||||
|
return resources.read_binary(package, resource)
|
||||||
|
except (FileNotFoundError, ModuleNotFoundError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_tz_string(tzfile: bytes) -> str:
|
||||||
|
try:
|
||||||
|
return tzfile.split(b"\n")[-2].decode()
|
||||||
|
except (IndexError, UnicodeDecodeError):
|
||||||
|
_LOGGER.error("Could not determine TZ string. Please report this issue.")
|
||||||
|
_LOGGER.error("tzfile contents: %s", tzfile, exc_info=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def detect_tz() -> str:
|
||||||
|
localzone = tzlocal.get_localzone()
|
||||||
|
if localzone is timezone.utc:
|
||||||
|
return "UTC0"
|
||||||
|
if not hasattr(localzone, "key"):
|
||||||
|
raise cv.Invalid(
|
||||||
|
"Could not automatically determine timezone, please set timezone manually."
|
||||||
|
)
|
||||||
|
iana_key = localzone.key
|
||||||
|
_LOGGER.info("Detected timezone '%s'", iana_key)
|
||||||
|
tzfile = _load_tzdata(iana_key)
|
||||||
|
if tzfile is None:
|
||||||
|
raise cv.Invalid(
|
||||||
|
"Could not automatically determine timezone, please set timezone manually."
|
||||||
|
)
|
||||||
|
ret = _extract_tz_string(tzfile)
|
||||||
|
_LOGGER.debug(" -> TZ string %s", ret)
|
||||||
|
return ret
|
||||||
|
|
||||||
|
|
||||||
def _parse_cron_int(value, special_mapping, message):
|
def _parse_cron_int(value, special_mapping, message):
|
||||||
|
@ -327,15 +261,15 @@ def validate_cron_keys(value):
|
||||||
return cv.has_at_least_one_key(*CRON_KEYS)(value)
|
return cv.has_at_least_one_key(*CRON_KEYS)(value)
|
||||||
|
|
||||||
|
|
||||||
def validate_tz(value):
|
def validate_tz(value: str) -> str:
|
||||||
value = cv.string_strict(value)
|
value = cv.string_strict(value)
|
||||||
|
|
||||||
try:
|
tzfile = _load_tzdata(value)
|
||||||
pytz_obj = pytz.timezone(value)
|
if tzfile is None:
|
||||||
except pytz.UnknownTimeZoneError: # pylint: disable=broad-except
|
# Not a IANA key, probably a TZ string
|
||||||
return value
|
return value
|
||||||
|
|
||||||
return convert_tz(pytz_obj)
|
return _extract_tz_string(tzfile)
|
||||||
|
|
||||||
|
|
||||||
TIME_SCHEMA = cv.Schema(
|
TIME_SCHEMA = cv.Schema(
|
||||||
|
|
|
@ -3,8 +3,8 @@ PyYAML==5.4.1
|
||||||
paho-mqtt==1.5.1
|
paho-mqtt==1.5.1
|
||||||
colorama==0.4.4
|
colorama==0.4.4
|
||||||
tornado==6.1
|
tornado==6.1
|
||||||
tzlocal==3.0
|
tzlocal==3.0 # from time
|
||||||
pytz==2021.1
|
tzdata>=2021.1 # from time
|
||||||
pyserial==3.5
|
pyserial==3.5
|
||||||
platformio==5.2.0
|
platformio==5.2.0
|
||||||
esptool==3.1
|
esptool==3.1
|
||||||
|
|
Loading…
Reference in a new issue