Skip to content

Commit

Permalink
Add datetime platform (#81943)
Browse files Browse the repository at this point in the history
Co-authored-by: Franck Nijhof <frenck@frenck.nl>
Co-authored-by: Franck Nijhof <git@frenck.dev>
  • Loading branch information
3 people committed May 29, 2023
1 parent 940942a commit 24290e5
Show file tree
Hide file tree
Showing 17 changed files with 468 additions and 30 deletions.
1 change: 1 addition & 0 deletions .core_files.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ base_platforms: &base_platforms
- homeassistant/components/climate/**
- homeassistant/components/cover/**
- homeassistant/components/date/**
- homeassistant/components/datetime/**
- homeassistant/components/device_tracker/**
- homeassistant/components/diagnostics/**
- homeassistant/components/fan/**
Expand Down
2 changes: 2 additions & 0 deletions CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,8 @@ build.json @home-assistant/supervisor
/tests/components/daikin/ @fredrike
/homeassistant/components/date/ @home-assistant/core
/tests/components/date/ @home-assistant/core
/homeassistant/components/datetime/ @home-assistant/core
/tests/components/datetime/ @home-assistant/core
/homeassistant/components/debugpy/ @frenck
/tests/components/debugpy/ @frenck
/homeassistant/components/deconz/ @Kane610
Expand Down
8 changes: 4 additions & 4 deletions homeassistant/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from . import config as conf_util, config_entries, core, loader
from .components import http
from .const import (
FORMAT_DATETIME,
REQUIRED_NEXT_PYTHON_HA_RELEASE,
REQUIRED_NEXT_PYTHON_VER,
SIGNAL_BOOTSTRAP_INTEGRATIONS,
Expand Down Expand Up @@ -347,7 +348,6 @@ def async_enable_logging(
fmt = (
"%(asctime)s.%(msecs)03d %(levelname)s (%(threadName)s) [%(name)s] %(message)s"
)
datefmt = "%Y-%m-%d %H:%M:%S"

if not log_no_color:
try:
Expand All @@ -362,7 +362,7 @@ def async_enable_logging(
logging.getLogger().handlers[0].setFormatter(
ColoredFormatter(
colorfmt,
datefmt=datefmt,
datefmt=FORMAT_DATETIME,
reset=True,
log_colors={
"DEBUG": "cyan",
Expand All @@ -378,7 +378,7 @@ def async_enable_logging(

# If the above initialization failed for any reason, setup the default
# formatting. If the above succeeds, this will result in a no-op.
logging.basicConfig(format=fmt, datefmt=datefmt, level=logging.INFO)
logging.basicConfig(format=fmt, datefmt=FORMAT_DATETIME, level=logging.INFO)

# Capture warnings.warn(...) and friends messages in logs.
# The standard destination for them is stderr, which may end up unnoticed.
Expand Down Expand Up @@ -435,7 +435,7 @@ def async_enable_logging(
_LOGGER.error("Error rolling over log file: %s", err)

err_handler.setLevel(logging.INFO if verbose else logging.WARNING)
err_handler.setFormatter(logging.Formatter(fmt, datefmt=datefmt))
err_handler.setFormatter(logging.Formatter(fmt, datefmt=FORMAT_DATETIME))

logger = logging.getLogger("")
logger.addHandler(err_handler)
Expand Down
126 changes: 126 additions & 0 deletions homeassistant/components/datetime/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
"""Component to allow setting date/time as platforms."""
from __future__ import annotations

from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
import logging
from typing import final

import voluptuous as vol

from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.config_validation import ( # noqa: F401
ENTITY_SERVICE_FIELDS,
PLATFORM_SCHEMA,
PLATFORM_SCHEMA_BASE,
)
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util

from .const import ATTR_DATETIME, DOMAIN, SERVICE_SET_VALUE

SCAN_INTERVAL = timedelta(seconds=30)

ENTITY_ID_FORMAT = DOMAIN + ".{}"

_LOGGER = logging.getLogger(__name__)

__all__ = ["ATTR_DATETIME", "DOMAIN", "DateTimeEntity", "DateTimeEntityDescription"]


async def _async_set_value(entity: DateTimeEntity, service_call: ServiceCall) -> None:
"""Service call wrapper to set a new date/time."""
value: datetime = service_call.data[ATTR_DATETIME]
if value.tzinfo is None:
value = value.replace(
tzinfo=dt_util.get_time_zone(entity.hass.config.time_zone)
)
return await entity.async_set_value(value)


async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up Date/Time entities."""
component = hass.data[DOMAIN] = EntityComponent[DateTimeEntity](
_LOGGER, DOMAIN, hass, SCAN_INTERVAL
)
await component.async_setup(config)

component.async_register_entity_service(
SERVICE_SET_VALUE,
{
vol.Required(ATTR_DATETIME): cv.datetime,
**ENTITY_SERVICE_FIELDS,
},
_async_set_value,
)

return True


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a config entry."""
component: EntityComponent[DateTimeEntity] = hass.data[DOMAIN]
return await component.async_setup_entry(entry)


async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
component: EntityComponent[DateTimeEntity] = hass.data[DOMAIN]
return await component.async_unload_entry(entry)


@dataclass
class DateTimeEntityDescription(EntityDescription):
"""A class that describes date/time entities."""


class DateTimeEntity(Entity):
"""Representation of a Date/time entity."""

entity_description: DateTimeEntityDescription
_attr_device_class: None = None
_attr_state: None = None
_attr_native_value: datetime | None

@property
@final
def device_class(self) -> None:
"""Return entity device class."""
return None

@property
@final
def state_attributes(self) -> None:
"""Return the state attributes."""
return None

@property
@final
def state(self) -> str | None:
"""Return the entity state."""
if (value := self.native_value) is None:
return None
if value.tzinfo is None:
raise ValueError(
f"Invalid datetime: {self.entity_id} provides state '{value}', "
"which is missing timezone information"
)

return value.astimezone(timezone.utc).isoformat(timespec="seconds")

@property
def native_value(self) -> datetime | None:
"""Return the value reported by the datetime."""
return self._attr_native_value

def set_value(self, value: datetime) -> None:
"""Change the date/time."""
raise NotImplementedError()

async def async_set_value(self, value: datetime) -> None:
"""Change the date/time."""
await self.hass.async_add_executor_job(self.set_value, value)
7 changes: 7 additions & 0 deletions homeassistant/components/datetime/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"""Provides the constants needed for the component."""

DOMAIN = "datetime"

ATTR_DATETIME = "datetime"

SERVICE_SET_VALUE = "set_value"
8 changes: 8 additions & 0 deletions homeassistant/components/datetime/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"domain": "datetime",
"name": "Date/Time",
"codeowners": ["@home-assistant/core"],
"documentation": "https://www.home-assistant.io/integrations/datetime",
"integration_type": "entity",
"quality_scale": "internal"
}
14 changes: 14 additions & 0 deletions homeassistant/components/datetime/services.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
set_value:
name: Set Date/Time
description: Set the date/time for a datetime entity.
target:
entity:
domain: datetime
fields:
datetime:
name: Date & Time
description: The date/time to set. The time zone of the Home Assistant instance is assumed.
required: true
example: "2022/11/01 22:15"
selector:
datetime:
8 changes: 8 additions & 0 deletions homeassistant/components/datetime/strings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"title": "Date/Time",
"entity_component": {
"_": {
"name": "[%key:component::datetime::title%]"
}
}
}
1 change: 1 addition & 0 deletions homeassistant/components/demo/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
Platform.CLIMATE,
Platform.COVER,
Platform.DATE,
Platform.DATETIME,
Platform.FAN,
Platform.HUMIDIFIER,
Platform.LIGHT,
Expand Down
77 changes: 77 additions & 0 deletions homeassistant/components/demo/datetime.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""Demo platform that offers a fake date/time entity."""
from __future__ import annotations

from datetime import datetime, timezone

from homeassistant.components.datetime import DateTimeEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import DEVICE_DEFAULT_NAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType

from . import DOMAIN


async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Demo date/time entity."""
async_add_entities(
[
DemoDateTime(
"datetime",
"Date and Time",
datetime(2020, 1, 1, 12, 0, 0, tzinfo=timezone.utc),
"mdi:calendar-clock",
False,
),
]
)


async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Demo config entry."""
await async_setup_platform(hass, {}, async_add_entities)


class DemoDateTime(DateTimeEntity):
"""Representation of a Demo date/time entity."""

_attr_should_poll = False

def __init__(
self,
unique_id: str,
name: str,
state: datetime,
icon: str,
assumed_state: bool,
) -> None:
"""Initialize the Demo date/time entity."""
self._attr_assumed_state = assumed_state
self._attr_icon = icon
self._attr_name = name or DEVICE_DEFAULT_NAME
self._attr_native_value = state
self._attr_unique_id = unique_id

self._attr_device_info = DeviceInfo(
identifiers={
# Serial numbers are unique identifiers within a specific domain
(DOMAIN, unique_id)
},
name=self.name,
)

async def async_set_value(self, value: datetime) -> None:
"""Update the date/time."""
self._attr_native_value = value
self.async_write_ha_state()
2 changes: 1 addition & 1 deletion homeassistant/components/input_datetime/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,7 @@ async def async_added_to_hass(self):
if self.state is not None:
return

default_value = py_datetime.datetime.today().strftime("%Y-%m-%d 00:00:00")
default_value = py_datetime.datetime.today().strftime(f"{FMT_DATE} 00:00:00")

# Priority 2: Old state
if (old_state := await self.async_get_last_state()) is None:
Expand Down
6 changes: 6 additions & 0 deletions homeassistant/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ class Platform(StrEnum):
CLIMATE = "climate"
COVER = "cover"
DATE = "date"
DATETIME = "datetime"
DEVICE_TRACKER = "device_tracker"
FAN = "fan"
GEO_LOCATION = "geo_location"
Expand Down Expand Up @@ -1165,6 +1166,11 @@ class UnitOfDataRate(StrEnum):

SIGNAL_BOOTSTRAP_INTEGRATIONS = "bootstrap_integrations"

# Date/Time formats
FORMAT_DATE: Final = "%Y-%m-%d"
FORMAT_TIME: Final = "%H:%M:%S"
FORMAT_DATETIME: Final = f"{FORMAT_DATE} {FORMAT_TIME}"


class EntityCategory(StrEnum):
"""Category of an entity.
Expand Down
1 change: 1 addition & 0 deletions tests/components/datetime/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Tests for the datetime component."""
Loading

0 comments on commit 24290e5

Please sign in to comment.