Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 4 additions & 7 deletions src/custom_components/wattplan/adapter_auto.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
import re
from typing import Any

from .datetime_utils import parse_datetime_like


@dataclass(frozen=True, slots=True)
class AdapterAutoDetectResult:
Expand Down Expand Up @@ -66,13 +68,8 @@ def iter_candidate_lists(root: Any, prefix: str = "") -> list[tuple[str, list[An


def _coerce_timestamp(value: Any) -> datetime | None:
"""Return parsed datetime when the value looks like an ISO timestamp."""
if not isinstance(value, str):
return None
try:
return datetime.fromisoformat(value)
except ValueError:
return None
"""Return parsed datetime when the value looks like a timestamp."""
return parse_datetime_like(value)


def _coerce_decimal(value: Any) -> float | None:
Expand Down
34 changes: 10 additions & 24 deletions src/custom_components/wattplan/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@
SUBENTRY_TYPE_COMFORT,
SUBENTRY_TYPE_OPTIONAL,
)
from .datetime_utils import parse_datetime_like
from .forecast_provider import ForecastProvider
from .source_pipeline import build_source_base_provider, build_source_value_provider
from .source_provider import (
Expand Down Expand Up @@ -140,12 +141,7 @@ def _format_coverage_datetime(
value: str | datetime, timezone_name: str | None
) -> str:
"""Format coverage datetimes in the Home Assistant local timezone."""
parsed = value
if isinstance(value, str):
try:
parsed = datetime.fromisoformat(value)
except ValueError:
return value
parsed = parse_datetime_like(value)
if not isinstance(parsed, datetime):
return str(value)
try:
Expand Down Expand Up @@ -480,20 +476,14 @@ def _built_in_history_coverage(

rows: list[datetime] = []
for row in debug.get("raw_history_states", []):
last_changed = row.get("last_changed")
if isinstance(last_changed, str):
try:
rows.append(datetime.fromisoformat(last_changed))
except ValueError:
continue
last_changed = parse_datetime_like(row.get("last_changed"))
if last_changed is not None:
rows.append(last_changed)
if not rows:
for row in debug.get("raw_statistics_rows", []):
started = row.get("start")
if isinstance(started, str):
try:
rows.append(datetime.fromisoformat(started))
except ValueError:
continue
started = parse_datetime_like(row.get("start"))
if started is not None:
rows.append(started)
if not rows:
return start_at, start_at, 0.0
coverage_start = min(rows)
Expand Down Expand Up @@ -524,12 +514,8 @@ def _summarize_payload_coverage(
for point in payload:
if not isinstance(point, dict):
continue
stamp = point.get(time_key)
if not isinstance(stamp, str):
continue
try:
point_dt = datetime.fromisoformat(stamp)
except ValueError:
point_dt = parse_datetime_like(point.get(time_key))
if point_dt is None:
continue
point_dt = floor_to_slot(
point_dt.astimezone(UTC)
Expand Down
14 changes: 5 additions & 9 deletions src/custom_components/wattplan/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
SUBENTRY_TYPE_COMFORT,
SUBENTRY_TYPE_OPTIONAL,
)
from .datetime_utils import parse_datetime_like
from .historical_on_off_provider import HistoricalOnOffProvider
from .optimizer import OptimizationParams, optimize
from .source_fixup import SourceFixupProvider, SourceHealthKind
Expand Down Expand Up @@ -103,15 +104,10 @@ def _snapshot_schema_id() -> str:

def _parse_datetime(value: Any) -> datetime | None:
"""Parse a datetime-like restore value."""
if isinstance(value, datetime):
return value.astimezone(UTC) if value.tzinfo else value.replace(tzinfo=UTC)
if isinstance(value, str):
try:
parsed = datetime.fromisoformat(value)
except ValueError:
return None
return parsed.astimezone(UTC) if parsed.tzinfo else parsed.replace(tzinfo=UTC)
return None
parsed = parse_datetime_like(value)
if parsed is None:
return None
return parsed.astimezone(UTC) if parsed.tzinfo else parsed.replace(tzinfo=UTC)


SCHEDULE_OFFSET = timedelta(seconds=2)
Expand Down
18 changes: 18 additions & 0 deletions src/custom_components/wattplan/datetime_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"""Datetime parsing helpers shared across WattPlan."""

from __future__ import annotations

from datetime import datetime
from typing import Any


def parse_datetime_like(value: Any) -> datetime | None:
"""Return a datetime for native datetimes or ISO-8601 strings."""
if isinstance(value, datetime):
return value
if not isinstance(value, str):
return None
try:
return datetime.fromisoformat(value)
except ValueError:
return None
17 changes: 14 additions & 3 deletions src/custom_components/wattplan/optimizer/mpc_power_optimizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -1169,6 +1169,18 @@ def _optional_entity_options(entity, grid_import_prices, baseline_net_import):
]


def _battery_schedule_charge_source(result, battery_index: int, timeslot: int) -> int:
"""Return a normalized charge source bitmask for one battery schedule slot."""
battery_state = int(result["battery_states"][battery_index, timeslot])
if battery_state == 0:
return 0

return int(
(1 if result["battery_charge_grid"][battery_index, timeslot] > EPSILON else 0)
| (2 if result["battery_charge_pv"][battery_index, timeslot] > EPSILON else 0)
)


def optimize_internal(normalized: CalculationInput):
total_steps = normalized.total_steps
grid_import_prices = normalized.grid_import_prices
Expand Down Expand Up @@ -1266,9 +1278,8 @@ def optimize_internal(normalized: CalculationInput):
"state": battery_state_name[
int(result["battery_states"][i, t])
],
"charge_source": int(
(1 if result["battery_charge_grid"][i, t] > EPSILON else 0)
| (2 if result["battery_charge_pv"][i, t] > EPSILON else 0)
"charge_source": _battery_schedule_charge_source(
result, i, t
),
"level": float(result["battery_levels"][i, t + 1]),
}
Expand Down
9 changes: 4 additions & 5 deletions src/custom_components/wattplan/repairs.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from homeassistant.helpers import issue_registry as ir

from .const import DOMAIN
from .datetime_utils import parse_datetime_like
from .source_issues import (
_covered_hours,
source_display_name,
Expand Down Expand Up @@ -111,11 +112,9 @@ async def async_create_fix_flow(
available_count = int(data.get("available_count", 0))
required_count = int(data.get("required_count", 0))
slot_minutes = int(entry.data.get("slot_minutes", 60)) if entry else 60
if expires_at := data.get("expires_at"):
expires_dt = datetime.fromisoformat(str(expires_at)).astimezone()
expires_local = datetime.fromisoformat(str(expires_at)).astimezone().strftime(
"%Y-%m-%d %H:%M %Z"
)
if expires_at := parse_datetime_like(data.get("expires_at")):
expires_dt = expires_at.astimezone()
expires_local = expires_dt.strftime("%Y-%m-%d %H:%M %Z")
now_local = datetime.now(tz=expires_dt.tzinfo)
total_minutes = max(int((expires_dt - now_local).total_seconds() // 60), 0)
hours, minutes = divmod(total_minutes, 60)
Expand Down
28 changes: 19 additions & 9 deletions src/custom_components/wattplan/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
SUBENTRY_TYPE_OPTIONAL,
)
from .coordinator import CoordinatorSnapshot, WattPlanCoordinator
from .datetime_utils import parse_datetime_like

SUBOPTIMAL_REASON_DESCRIPTIONS: dict[str, str] = {
"battery_min_unmet": (
Expand All @@ -49,6 +50,13 @@
),
}

BATTERY_CHARGE_SOURCE_LABELS: dict[str, str] = {
"n": "(N)one",
"g": "(G)rid",
"p": "(P)V",
"gp": "(G)rid and (P)V",
}


def _subentry_slug(subentry: Any) -> str:
"""Return slug for subentry naming."""
Expand All @@ -72,14 +80,12 @@ def _entry_device_info(config_entry: ConfigEntry) -> DeviceInfo:

def _as_datetime(value: Any) -> datetime | None:
"""Convert a dynamic value to datetime when possible."""
if isinstance(value, datetime):
return value
if isinstance(value, str):
try:
return datetime.fromisoformat(value)
except ValueError:
return None
return None
return parse_datetime_like(value)


def _friendly_charge_source_label(charge_source: str) -> str:
"""Return a user-facing charge source label for compact planner codes."""
return BATTERY_CHARGE_SOURCE_LABELS.get(charge_source, charge_source)


class WattPlanCoordinatorSensor(CoordinatorEntity[WattPlanCoordinator], SensorEntity):
Expand Down Expand Up @@ -287,7 +293,11 @@ def extra_state_attributes(self) -> dict[str, str] | None:
attrs["next_action"] = next_action

if self._group == "batteries" and (charge_source := data.get("charge_source")):
attrs["charge_source"] = str(charge_source)
charge_source_code = str(charge_source)
attrs["charge_source"] = charge_source_code
attrs["charge_source_friendly"] = _friendly_charge_source_label(
charge_source_code
)

return attrs or None

Expand Down
31 changes: 17 additions & 14 deletions src/custom_components/wattplan/source_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
SOURCE_MODE_TEMPLATE,
)
from .forecast_provider import ForecastProvider
from .datetime_utils import parse_datetime_like
from .source_types import SourceProvider, SourceProviderError, SourceWindow

CONF_WATTPLAN_ENTITY_ID = "entity_id"
Expand Down Expand Up @@ -613,7 +614,7 @@ def _object_payload_to_points(

start_value = point.get(time_key)
numeric_value = point.get(value_key)
if not isinstance(start_value, str):
if not isinstance(start_value, (str, datetime)):
if strict:
raise SourceProviderError(
"source_parse",
Expand All @@ -623,11 +624,17 @@ def _object_payload_to_points(
continue

try:
start_dt = datetime.fromisoformat(start_value)
start_dt = parse_datetime_like(start_value)
if start_dt is None:
raise ValueError
value = float(numeric_value)
except (TypeError, ValueError) as err:
if strict:
field_name = time_key if not isinstance(start_value, str) else value_key
field_name = (
value_key
if isinstance(start_value, (str, datetime))
else time_key
)
raise SourceProviderError(
"source_parse",
(
Expand Down Expand Up @@ -725,16 +732,15 @@ def _object_values(

start_value = point.get(time_key)
numeric_value = point.get(value_key)
if not isinstance(start_value, str):
if not isinstance(start_value, (str, datetime)):
raise SourceProviderError(
"source_parse",
f"{self._source_name} point {index + 1} missing `{time_key}`",
details={"source": self._source_name, "index": index, "key": time_key},
)

try:
start_dt = datetime.fromisoformat(start_value)
except ValueError as err:
start_dt = parse_datetime_like(start_value)
if start_dt is None:
raise SourceProviderError(
"source_parse",
(
Expand All @@ -746,7 +752,7 @@ def _object_values(
"index": index,
"value": start_value,
},
) from err
)

try:
value = float(numeric_value)
Expand Down Expand Up @@ -1198,13 +1204,10 @@ def _energy_payload_slots(
slot_delta = timedelta(minutes=window.slot_minutes)
max_slot = 0
for point in payload:
stamp = point.get("start")
if not isinstance(stamp, str):
continue
try:
point_start = self._as_utc(datetime.fromisoformat(stamp))
except ValueError:
point_start = parse_datetime_like(point.get("start"))
if point_start is None:
continue
point_start = self._as_utc(point_start)

if self._clamp_mode == CLAMP_MODE_NEAREST:
slot_index = self._nearest_slot_index(point_start, start_at, slot_delta)
Expand Down
Loading
Loading