"""Plugwise Circle node object."""
from datetime import datetime, timedelta
import logging

from ..constants import (
    FEATURE_ENERGY_CONSUMPTION_TODAY,
    FEATURE_PING,
    FEATURE_POWER_CONSUMPTION_CURRENT_HOUR,
    FEATURE_POWER_CONSUMPTION_PREVIOUS_HOUR,
    FEATURE_POWER_CONSUMPTION_TODAY,
    FEATURE_POWER_CONSUMPTION_YESTERDAY,
    FEATURE_POWER_PRODUCTION_CURRENT_HOUR,
    FEATURE_POWER_USE,
    FEATURE_POWER_USE_LAST_8_SEC,
    FEATURE_RELAY,
    FEATURE_RSSI_IN,
    FEATURE_RSSI_OUT,
    MAX_TIME_DRIFT,
    MESSAGE_TIME_OUT,
    PRIORITY_HIGH,
    PRIORITY_LOW,
    PULSES_PER_KW_SECOND,
    RELAY_SWITCHED_OFF,
    RELAY_SWITCHED_ON,
)
from ..messages.requests import (
    CircleCalibrationRequest,
    CircleClockGetRequest,
    CircleClockSetRequest,
    CircleEnergyCountersRequest,
    CirclePowerUsageRequest,
    CircleSwitchRelayRequest,
)
from ..messages.responses import (
    CircleCalibrationResponse,
    CircleClockResponse,
    CircleEnergyCountersResponse,
    CirclePowerUsageResponse,
    NodeAckLargeResponse,
)
from ..nodes import PlugwiseNode

_LOGGER = logging.getLogger(__name__)


class PlugwiseCircle(PlugwiseNode):
    """provides interface to the Plugwise Circle nodes and base class for Circle+ nodes"""

    def __init__(self, mac, address, message_sender):
        super().__init__(mac, address, message_sender)
        self._features = (
            FEATURE_ENERGY_CONSUMPTION_TODAY["id"],
            FEATURE_PING["id"],
            FEATURE_POWER_USE["id"],
            FEATURE_POWER_USE_LAST_8_SEC["id"],
            FEATURE_POWER_CONSUMPTION_CURRENT_HOUR["id"],
            FEATURE_POWER_CONSUMPTION_PREVIOUS_HOUR["id"],
            FEATURE_POWER_CONSUMPTION_TODAY["id"],
            FEATURE_POWER_CONSUMPTION_YESTERDAY["id"],
            FEATURE_POWER_PRODUCTION_CURRENT_HOUR["id"],
            # FEATURE_POWER_PRODUCTION_PREVIOUS_HOUR["id"],
            FEATURE_RSSI_IN["id"],
            FEATURE_RSSI_OUT["id"],
            FEATURE_RELAY["id"],
        )
        self._energy_consumption_today_reset = datetime.now().replace(
            hour=0, minute=0, second=0, microsecond=0
        )
        self._energy_history_collecting = False
        self._energy_history = {}
        self._energy_last_collected_timestamp = datetime(2000, 1, 1)
        self._energy_last_rollover_timestamp = datetime(2000, 1, 1)
        self._energy_last_local_hour = datetime.now().hour
        self._energy_last_populated_slot = 0
        self._energy_pulses_current_hour = None
        self._energy_pulses_prev_hour = None
        self._energy_rollover_day_started = False
        self._energy_rollover_day_finished = True
        self._energy_rollover_history_started = False
        self._energy_rollover_history_finished = True
        self._energy_rollover_hour_started = False
        self._energy_rollover_hour_finished = True
        self._energy_pulses_today_hourly = None
        self._energy_pulses_today_now = None
        self._energy_pulses_yesterday = None
        self._new_relay_state = False
        self._new_relay_stamp = datetime.now() - timedelta(seconds=MESSAGE_TIME_OUT)
        self._pulses_1s = None
        self._pulses_8s = None
        self._pulses_produced_1h = None
        self.calibration = False
        self._gain_a = None
        self._gain_b = None
        self._off_noise = None
        self._off_tot = None
        self._measures_power = True
        self._last_log_collected = False
        self.timezone_delta = datetime.now().replace(
            minute=0, second=0, microsecond=0
        ) - datetime.utcnow().replace(minute=0, second=0, microsecond=0)
        self._clock_offset = None
        self.get_clock(self.sync_clock)
        self._request_calibration()

    @property
    def current_power_usage(self):
        """
        Returns power usage during the last second in Watts
        Based on last received power usage information
        """
        if self._pulses_1s is not None:
            return self.pulses_to_kws(self._pulses_1s) * 1000
        return None

    @property
    def current_power_usage_8_sec(self):
        """
        Returns power usage during the last 8 second in Watts
        Based on last received power usage information
        """
        if self._pulses_8s is not None:
            return self.pulses_to_kws(self._pulses_8s, 8) * 1000
        return None

    @property
    def energy_consumption_today(self) -> float:
        """Returns total energy consumption since midnight in kWh"""
        if self._energy_pulses_today_now is not None:
            return self.pulses_to_kws(self._energy_pulses_today_now, 3600)
        return None

    @property
    def energy_consumption_today_last_reset(self):
        """Last reset of total energy consumption today"""
        return self._energy_consumption_today_reset

    @property
    def power_consumption_current_hour(self):
        """
        Returns the power usage during this running hour in kWh
        Based on last received power usage information
        """
        if self._energy_pulses_current_hour is not None:
            return self.pulses_to_kws(self._energy_pulses_current_hour, 3600)
        return None

    @property
    def power_consumption_previous_hour(self):
        """Returns power consumption during the previous hour in kWh"""
        if self._energy_pulses_prev_hour is not None:
            return self.pulses_to_kws(self._energy_pulses_prev_hour, 3600)
        return None

    @property
    def power_consumption_today(self):
        """Total power consumption during today in kWh"""
        if self._energy_pulses_today_hourly is not None:
            return self.pulses_to_kws(self._energy_pulses_today_hourly, 3600)
        return None

    @property
    def power_consumption_yesterday(self):
        """Total power consumption of yesterday in kWh"""
        if self._energy_pulses_yesterday is not None:
            return self.pulses_to_kws(self._energy_pulses_yesterday, 3600)
        return None

    @property
    def power_production_current_hour(self):
        """
        Returns the power production during this running hour in kWh
        Based on last received power usage information
        """
        if self._pulses_produced_1h is not None:
            return self.pulses_to_kws(self._pulses_produced_1h, 3600)
        return None

    @property
    def relay_state(self) -> bool:
        """
        Return last known relay state or the new switch state by anticipating
        the acknowledge for new state is getting in before message timeout.
        """
        if self._new_relay_stamp + timedelta(seconds=MESSAGE_TIME_OUT) > datetime.now():
            return self._new_relay_state
        return self._relay_state

    @relay_state.setter
    def relay_state(self, state):
        """Request the relay to switch state."""
        self._request_switch(state)
        self._new_relay_state = state
        self._new_relay_stamp = datetime.now()
        if state != self._relay_state:
            self.do_callback(FEATURE_RELAY["id"])

    def _request_calibration(self, callback=None):
        """Request calibration info"""
        self.message_sender(
            CircleCalibrationRequest(self._mac),
            callback,
            0,
            PRIORITY_HIGH,
        )

    def _request_switch(self, state, callback=None):
        """Request to switch relay state and request state info"""
        self.message_sender(
            CircleSwitchRelayRequest(self._mac, state),
            callback,
            0,
            PRIORITY_HIGH,
        )

    def request_power_update(self, callback=None):
        """Request power usage and update energy counters"""
        if self._available:
            self.message_sender(
                CirclePowerUsageRequest(self._mac),
                callback,
            )
            if len(self._energy_history) > 0:
                # Request new energy counters if last one is more than one hour ago
                if self._energy_last_collected_timestamp < datetime.utcnow().replace(
                    minute=0, second=0, microsecond=0
                ):
                    self.request_energy_counters()
            else:
                # No history collected yet, request energy history
                self.request_energy_counters()

    def message_for_circle(self, message):
        """
        Process received message
        """
        if isinstance(message, CirclePowerUsageResponse):
            if self.calibration:
                self._response_power_usage(message)
                _LOGGER.debug(
                    "Power update for %s, last update %s",
                    self.mac,
                    str(self._last_update),
                )
            else:
                _LOGGER.info(
                    "Received power update for %s before calibration information is known",
                    self.mac,
                )
                self._request_calibration(self.request_power_update)
        elif isinstance(message, NodeAckLargeResponse):
            self._node_ack_response(message)
        elif isinstance(message, CircleCalibrationResponse):
            self._response_calibration(message)
        elif isinstance(message, CircleEnergyCountersResponse):
            if self.calibration:
                self._response_energy_counters(message)
            else:
                _LOGGER.debug(
                    "Received power buffer log for %s before calibration information is known",
                    self.mac,
                )
                self._request_calibration(self.request_energy_counters)
        elif isinstance(message, CircleClockResponse):
            self._response_clock(message)
        else:
            self.message_for_circle_plus(message)

    def message_for_circle_plus(self, message):
        """Pass messages to PlugwiseCirclePlus class"""

    def _node_ack_response(self, message):
        """Process switch response message"""
        if message.ack_id == RELAY_SWITCHED_ON:
            if not self._relay_state:
                _LOGGER.debug(
                    "Switch relay on for %s",
                    self.mac,
                )
                self._relay_state = True
                self.do_callback(FEATURE_RELAY["id"])
        elif message.ack_id == RELAY_SWITCHED_OFF:
            if self._relay_state:
                _LOGGER.debug(
                    "Switch relay off for %s",
                    self.mac,
                )
                self._relay_state = False
                self.do_callback(FEATURE_RELAY["id"])
        else:
            _LOGGER.debug(
                "Unmanaged _node_ack_response %s received for %s",
                str(message.ack_id),
                self.mac,
            )

    def _response_power_usage(self, message: CirclePowerUsageResponse):
        # Sometimes the circle returns -1 for some of the pulse counters
        # likely this means the circle measures very little power and is suffering from
        # rounding errors. Zero these out. However, negative pulse values are valid
        # for power producing appliances, like solar panels, so don't complain too loudly.

        # Power consumption last second
        if message.pulse_1s.value == -1:
            message.pulse_1s.value = 0
            _LOGGER.debug(
                "1 sec power pulse counter for node %s has value of -1, corrected to 0",
                self.mac,
            )
        self._pulses_1s = message.pulse_1s.value
        if message.pulse_1s.value != 0:
            if message.nanosecond_offset.value != 0:
                pulses_1s = (
                    message.pulse_1s.value
                    * (1000000000 + message.nanosecond_offset.value)
                ) / 1000000000
            else:
                pulses_1s = message.pulse_1s.value
            self._pulses_1s = pulses_1s
        else:
            self._pulses_1s = 0
        self.do_callback(FEATURE_POWER_USE["id"])
        # Power consumption last 8 seconds
        if message.pulse_8s.value == -1:
            message.pulse_8s.value = 0
            _LOGGER.debug(
                "8 sec power pulse counter for node %s has value of -1, corrected to 0",
                self.mac,
            )
        if message.pulse_8s.value != 0:
            if message.nanosecond_offset.value != 0:
                pulses_8s = (
                    message.pulse_8s.value
                    * (1000000000 + message.nanosecond_offset.value)
                ) / 1000000000
            else:
                pulses_8s = message.pulse_8s.value
            self._pulses_8s = pulses_8s
        else:
            self._pulses_8s = 0
        self.do_callback(FEATURE_POWER_USE_LAST_8_SEC["id"])
        # Power consumption current hour
        if message.pulse_hour_consumed.value == -1:
            _LOGGER.debug(
                "1 hour consumption power pulse counter for node %s has value of -1, drop value",
                self.mac,
            )
        else:
            self._update_energy_current_hour(message.pulse_hour_consumed.value)

        # Power produced current hour
        if message.pulse_hour_produced.value == -1:
            message.pulse_hour_produced.value = 0
            _LOGGER.debug(
                "1 hour power production pulse counter for node %s has value of -1, corrected to 0",
                self.mac,
            )
        if self._pulses_produced_1h != message.pulse_hour_produced.value:
            self._pulses_produced_1h = message.pulse_hour_produced.value
            self.do_callback(FEATURE_POWER_PRODUCTION_CURRENT_HOUR["id"])

    def _response_calibration(self, message: CircleCalibrationResponse):
        """Store calibration properties"""
        for calibration in ("gain_a", "gain_b", "off_noise", "off_tot"):
            val = getattr(message, calibration).value
            setattr(self, "_" + calibration, val)
        self.calibration = True

    def pulses_to_kws(self, pulses, seconds=1):
        """
        converts the amount of pulses to kWs using the calaboration offsets
        """
        if pulses is None:
            return None
        if pulses == 0 or not self.calibration:
            return 0.0
        pulses_per_s = pulses / float(seconds)
        corrected_pulses = seconds * (
            (
                (((pulses_per_s + self._off_noise) ** 2) * self._gain_b)
                + ((pulses_per_s + self._off_noise) * self._gain_a)
            )
            + self._off_tot
        )
        calc_value = corrected_pulses / PULSES_PER_KW_SECOND / seconds
        # Fix minor miscalculations
        if -0.001 < calc_value < 0.001:
            calc_value = 0.0
        return calc_value

    def _collect_energy_pulses(self, start_utc: datetime, end_utc: datetime):
        """Return energy pulses of given hours"""

        if start_utc == end_utc:
            hours = 0
        else:
            hours = int((end_utc - start_utc).seconds / 3600)
        _energy_history_failed = False
        _energy_pulses = 0
        for hour in range(0, hours + 1):
            _log_timestamp = start_utc + timedelta(hours=hour)
            if self._energy_history.get(_log_timestamp) is not None:
                _energy_pulses += self._energy_history[_log_timestamp]
                _LOGGER.debug(
                    "_collect_energy_pulses for %s | %s : %s, total = %s",
                    self.mac,
                    str(_log_timestamp),
                    str(self._energy_history[_log_timestamp]),
                    str(_energy_pulses),
                )
            else:
                _mem_address = self._energy_timestamp_memory_address(_log_timestamp)
                _LOGGER.info(
                    "_collect_energy_pulses for %s at %s not found, request counter from memory %s (from mem=%s, slot=%s, timestamp=%s)",
                    self.mac,
                    str(_log_timestamp),
                    str(_mem_address),
                    str(self._last_log_address),
                    str(self._energy_last_populated_slot),
                    str(self._energy_last_collected_timestamp),
                )
                self.request_energy_counters(_mem_address)
                _energy_history_failed = True

        # Validate all history values where present
        if not _energy_history_failed:
            return _energy_pulses
        return None

    def _update_energy_current_hour(self, _pulses_cur_hour):
        """Update energy consumption (pulses) of current hour"""
        _LOGGER.info(
            "_update_energy_current_hour for %s | counter = %s, update= %s",
            self.mac,
            str(self._energy_pulses_current_hour),
            str(_pulses_cur_hour),
        )
        _hour_rollover = False
        if self._energy_pulses_current_hour is None:
            self._energy_pulses_current_hour = _pulses_cur_hour
            self.do_callback(FEATURE_POWER_CONSUMPTION_CURRENT_HOUR["id"])
        else:
            if self._energy_pulses_current_hour != _pulses_cur_hour:
                if self._energy_pulses_current_hour > _pulses_cur_hour:
                    _hour_rollover = True
                self._energy_pulses_current_hour = _pulses_cur_hour
                self.do_callback(FEATURE_POWER_CONSUMPTION_CURRENT_HOUR["id"])
            # Update today
            self._update_energy_today_now(_hour_rollover, False, False)

    def _update_energy_today_now(
        self, hour_rollover=False, history_rollover=False, day_rollover=False
    ):
        """Update energy consumption (pulses) of today up to now"""

        _pulses_today_now = None

        # Check for rollovers triggers
        if hour_rollover and self._energy_rollover_hour_finished:
            self._energy_rollover_hour_started = True
            self._energy_rollover_hour_finished = False
        if history_rollover and self._energy_rollover_history_finished:
            self._energy_rollover_history_started = True
            self._energy_rollover_history_finished = False
        if day_rollover and self._energy_rollover_day_finished:
            self._energy_rollover_day_started = True
            self._energy_rollover_day_finished = False

        # Set counter
        if self._energy_rollover_hour_started:
            if self._energy_rollover_history_started:
                if self._energy_rollover_day_started:
                    # Day rollover, reset to only current hour
                    _pulses_today_now = self._energy_pulses_current_hour
                    self._energy_rollover_day_started = False
                    self._energy_rollover_day_finished = True
                else:
                    # Hour rollover, reset to hour history with current hour
                    if (
                        self._energy_pulses_today_hourly is None
                        or self._energy_pulses_current_hour is None
                    ):
                        _pulses_today_now = None
                    else:
                        _pulses_today_now = (
                            self._energy_pulses_today_hourly
                            + self._energy_pulses_current_hour
                        )
                self._energy_rollover_hour_started = False
                self._energy_rollover_hour_finished = True
                self._energy_rollover_history_started = False
                self._energy_rollover_history_finished = True
            else:
                # Wait for history_rollover, keep current counter
                _pulses_today_now = None
        else:
            if self._energy_rollover_history_started:
                # Wait for hour_rollover, keep current counter
                _pulses_today_now = None
            else:
                # Regular update
                if (
                    self._energy_pulses_today_hourly is None
                    or self._energy_pulses_current_hour is None
                ):
                    _pulses_today_now = None
                else:
                    _pulses_today_now = (
                        self._energy_pulses_today_hourly
                        + self._energy_pulses_current_hour
                    )

        if _pulses_today_now is None:
            _LOGGER.info(
                "_update_energy_today_now for %s | skip update, hour: %s=%s=%s, history: %s=%s=%s, day: %s=%s=%s",
                self.mac,
                str(hour_rollover),
                str(self._energy_rollover_hour_started),
                str(self._energy_rollover_hour_finished),
                str(history_rollover),
                str(self._energy_rollover_history_started),
                str(self._energy_rollover_history_finished),
                str(day_rollover),
                str(self._energy_rollover_day_started),
                str(self._energy_rollover_day_finished),
            )
        else:
            _LOGGER.info(
                "_update_energy_today_now for %s | counter = %s, update= %s (%s + %s)",
                self.mac,
                str(self._energy_pulses_today_now),
                str(_pulses_today_now),
                str(self._energy_pulses_today_hourly),
                str(self._energy_pulses_current_hour),
            )
            if self._energy_pulses_today_now is None:
                self._energy_pulses_today_now = _pulses_today_now
                if self._energy_pulses_today_now is not None:
                    self.do_callback(FEATURE_ENERGY_CONSUMPTION_TODAY["id"])
            else:
                if self._energy_pulses_today_now != _pulses_today_now:
                    self._energy_pulses_today_now = _pulses_today_now
                    self.do_callback(FEATURE_ENERGY_CONSUMPTION_TODAY["id"])

    def _update_energy_previous_hour(self, prev_hour: datetime):
        """Update energy consumption (pulses) of previous hour"""
        _pulses_prev_hour = self._collect_energy_pulses(prev_hour, prev_hour)
        _LOGGER.info(
            "_update_energy_previous_hour for %s | counter = %s, update= %s, timestamp %s",
            self.mac,
            str(self._energy_pulses_yesterday),
            str(_pulses_prev_hour),
            str(prev_hour),
        )
        if self._energy_pulses_prev_hour is None:
            self._energy_pulses_prev_hour = _pulses_prev_hour
            if self._energy_pulses_prev_hour is not None:
                self.do_callback(FEATURE_POWER_CONSUMPTION_PREVIOUS_HOUR["id"])
        else:
            if self._energy_pulses_prev_hour != _pulses_prev_hour:
                self._energy_pulses_prev_hour = _pulses_prev_hour
                self.do_callback(FEATURE_POWER_CONSUMPTION_PREVIOUS_HOUR["id"])

    def _update_energy_yesterday(
        self, start_yesterday: datetime, end_yesterday: datetime
    ):
        """Update energy consumption (pulses) of yesterday"""
        _pulses_yesterday = self._collect_energy_pulses(start_yesterday, end_yesterday)
        _LOGGER.debug(
            "_update_energy_yesterday for %s | counter = %s, update= %s, range %s to %s",
            self.mac,
            str(self._energy_pulses_yesterday),
            str(_pulses_yesterday),
            str(start_yesterday),
            str(end_yesterday),
        )
        if self._energy_pulses_yesterday is None:
            self._energy_pulses_yesterday = _pulses_yesterday
            if self._energy_pulses_yesterday is not None:
                self.do_callback(FEATURE_POWER_CONSUMPTION_YESTERDAY["id"])
        else:
            if self._energy_pulses_yesterday != _pulses_yesterday:
                self._energy_pulses_yesterday = _pulses_yesterday
                self.do_callback(FEATURE_POWER_CONSUMPTION_YESTERDAY["id"])

    def _update_energy_today_hourly(self, start_today: datetime, end_today: datetime):
        """Update energy consumption (pulses) of today up to last hour"""
        if start_today > end_today:
            _pulses_today_hourly = 0
        else:
            _pulses_today_hourly = self._collect_energy_pulses(start_today, end_today)
        _LOGGER.info(
            "_update_energy_today_hourly for %s | counter = %s, update= %s, range %s to %s",
            self.mac,
            str(self._energy_pulses_today_hourly),
            str(_pulses_today_hourly),
            str(start_today),
            str(end_today),
        )
        if self._energy_pulses_today_hourly is None:
            self._energy_pulses_today_hourly = _pulses_today_hourly
            if self._energy_pulses_today_hourly is not None:
                self.do_callback(FEATURE_POWER_CONSUMPTION_TODAY["id"])
        else:
            if self._energy_pulses_today_hourly != _pulses_today_hourly:
                self._energy_pulses_today_hourly = _pulses_today_hourly
                self.do_callback(FEATURE_POWER_CONSUMPTION_TODAY["id"])

    def request_energy_counters(self, log_address=None, callback=None):
        """Request power log of specified address"""
        _LOGGER.debug(
            "request_energy_counters for %s of address %s", self.mac, str(log_address)
        )
        if log_address is None:
            log_address = self._last_log_address
        if log_address is not None:
            if len(self._energy_history) > 48 or self._energy_history_collecting:
                # Energy history already collected
                if (
                    log_address == self._last_log_address
                    and self._energy_last_populated_slot == 4
                ):
                    # Rollover of energy counter slot, get new memory address first
                    self._energy_last_populated_slot = 0
                    self._request_info(self.request_energy_counters)
                else:
                    # Request new energy counters
                    self.message_sender(
                        CircleEnergyCountersRequest(self._mac, log_address),
                        None,
                        0,
                        PRIORITY_LOW,
                    )
            else:
                # Collect energy counters of today and yesterday
                # Each request contains will return 4 hours, except last request

                # TODO: validate range of log_addresses
                self._energy_history_collecting = True
                for req_log_address in range(log_address - 13, log_address):
                    self.message_sender(
                        CircleEnergyCountersRequest(self._mac, req_log_address),
                        None,
                        0,
                        PRIORITY_LOW,
                    )
                self.message_sender(
                    CircleEnergyCountersRequest(self._mac, log_address),
                    callback,
                    0,
                    PRIORITY_LOW,
                )

    def _response_energy_counters(self, message: CircleEnergyCountersResponse):
        """
        Save historical energy information in local counters
        Each response message contains 4 log counters (slots)
        of the energy pulses collected during the previous hour of given timestamp
        """
        if message.logaddr.value == self._last_log_address:
            self._energy_last_populated_slot = 0

        # Collect energy history pulses from received log address
        # Store pulse in self._energy_history using the timestamp in UTC as index
        _utc_hour_timestamp = datetime.utcnow().replace(
            minute=0, second=0, microsecond=0
        )
        _local_midnight_timestamp = datetime.now().replace(
            hour=0, minute=0, second=0, microsecond=0
        )
        _local_hour = datetime.now().hour
        _utc_midnight_timestamp = _utc_hour_timestamp - timedelta(hours=_local_hour)
        _midnight_rollover = False
        _history_rollover = False

        for _slot in range(1, 5):
            _log_timestamp = getattr(message, "logdate%d" % (_slot,)).value
            if _log_timestamp is None:
                break
            self._energy_history[_log_timestamp] = getattr(
                message, "pulses%d" % (_slot,)
            ).value

            # Store last populated _slot
            if message.logaddr.value == self._last_log_address:
                self._energy_last_populated_slot = _slot

            # Store most recent timestamp of collected pulses
            if self._energy_last_collected_timestamp < _log_timestamp:
                self._energy_last_collected_timestamp = _log_timestamp

            # Trigger history rollover
            if (
                _log_timestamp == _utc_hour_timestamp
                and self._energy_last_rollover_timestamp < _utc_hour_timestamp
            ):
                self._energy_last_rollover_timestamp = _utc_hour_timestamp
                _history_rollover = True
                _LOGGER.info(
                    "_response_energy_counters for %s | history rollover, reset date to %s",
                    self.mac,
                    str(_utc_hour_timestamp),
                )

            # Trigger midnight rollover
            if (
                _log_timestamp == _utc_midnight_timestamp
                and self._energy_consumption_today_reset < _local_midnight_timestamp
            ):
                _LOGGER.info(
                    "_response_energy_counters for %s | midnight rollover, reset date to %s",
                    self.mac,
                    str(_local_midnight_timestamp),
                )
                self._energy_consumption_today_reset = _local_midnight_timestamp
                _midnight_rollover = True

        # Reset energy collection progress
        if (
            self._energy_history_collecting
            and len(self._energy_history) > 48
            and self._energy_last_collected_timestamp == _utc_hour_timestamp
        ):
            self._energy_last_rollover_timestamp = self._energy_last_collected_timestamp
            self._energy_history_collecting = False
            _history_rollover = False
            _midnight_rollover = False
        else:
            _LOGGER.info(
                "_response_energy_counters for %s | collection not running, len=%s, timestamp:%s=%s",
                self.mac,
                str(len(self._energy_history)),
                str(self._energy_last_collected_timestamp),
                str(_utc_hour_timestamp),
            )

        # Update energy counters
        if not self._energy_history_collecting:
            self._update_energy_previous_hour(_utc_hour_timestamp)
            self._update_energy_today_hourly(
                _utc_midnight_timestamp + timedelta(hours=1),
                _utc_hour_timestamp,
            )
            self._update_energy_yesterday(
                _utc_midnight_timestamp - timedelta(hours=23),
                _utc_midnight_timestamp,
            )
            self._update_energy_today_now(False, _history_rollover, _midnight_rollover)
        else:
            _LOGGER.info(
                "_response_energy_counters for %s | self._energy_history_collecting running",
                self.mac,
                str(_local_midnight_timestamp),
            )

        # Cleanup energy history for more than 8 day's ago
        _8_days_ago = datetime.utcnow().replace(
            minute=0, second=0, microsecond=0
        ) - timedelta(days=8)
        for log_timestamp in list(self._energy_history.keys()):
            if log_timestamp < _8_days_ago:
                del self._energy_history[log_timestamp]

    def _response_clock(self, message: CircleClockResponse):
        log_date = datetime(
            datetime.now().year,
            datetime.now().month,
            datetime.now().day,
            message.time.value.hour,
            message.time.value.minute,
            message.time.value.second,
        )
        clock_offset = message.timestamp.replace(microsecond=0) - (
            log_date + self.timezone_delta
        )
        if clock_offset.days == -1:
            self._clock_offset = clock_offset.seconds - 86400
        else:
            self._clock_offset = clock_offset.seconds
        _LOGGER.debug(
            "Clock of node %s has drifted %s sec",
            self.mac,
            str(self._clock_offset),
        )

    def get_clock(self, callback=None):
        """get current datetime of internal clock of Circle."""
        self.message_sender(
            CircleClockGetRequest(self._mac),
            callback,
            0,
            PRIORITY_LOW,
        )

    def set_clock(self, callback=None):
        """set internal clock of CirclePlus."""
        self.message_sender(
            CircleClockSetRequest(self._mac, datetime.utcnow()),
            callback,
        )

    def sync_clock(self, max_drift=0):
        """Resync clock of node if time has drifted more than MAX_TIME_DRIFT"""
        if self._clock_offset is not None:
            if max_drift == 0:
                max_drift = MAX_TIME_DRIFT
            if (self._clock_offset > max_drift) or (self._clock_offset < -(max_drift)):
                _LOGGER.info(
                    "Reset clock of node %s because time has drifted %s sec",
                    self.mac,
                    str(self._clock_offset),
                )
                self.set_clock()

    def _energy_timestamp_memory_address(self, utc_timestamp: datetime):
        """Return memory address for given energy counter timestamp"""
        _utc_now_timestamp = datetime.utcnow().replace(
            minute=0, second=0, microsecond=0
        )
        if utc_timestamp > _utc_now_timestamp:
            return None

        _seconds_offset = (_utc_now_timestamp - utc_timestamp).seconds
        _hours_offset = _seconds_offset / 3600

        _slot = self._energy_last_populated_slot
        if _slot == 0:
            _slot = 4
        _address = self._last_log_address

        # last known
        _hours = 1
        while _hours <= _hours_offset:
            _slot -= 1
            if _slot == 0:
                _address -= 1
                _slot = 4
            _hours += 1

        return _address

"""Plugwise Circle node object."""
from datetime import datetime, timedelta
import logging

from ..constants import (
    FEATURE_ENERGY_CONSUMPTION_TODAY,
    FEATURE_PING,
    FEATURE_POWER_CONSUMPTION_CURRENT_HOUR,
    FEATURE_POWER_CONSUMPTION_PREVIOUS_HOUR,
    FEATURE_POWER_CONSUMPTION_TODAY,
    FEATURE_POWER_CONSUMPTION_YESTERDAY,
    FEATURE_POWER_PRODUCTION_CURRENT_HOUR,
    FEATURE_POWER_USE,
    FEATURE_POWER_USE_LAST_8_SEC,
    FEATURE_RELAY,
    FEATURE_RSSI_IN,
    FEATURE_RSSI_OUT,
    MAX_TIME_DRIFT,
    MESSAGE_TIME_OUT,
    PRIORITY_HIGH,
    PRIORITY_LOW,
    PULSES_PER_KW_SECOND,
    RELAY_SWITCHED_OFF,
    RELAY_SWITCHED_ON,
)
from ..messages.requests import (
    CircleCalibrationRequest,
    CircleClockGetRequest,
    CircleClockSetRequest,
    CircleEnergyCountersRequest,
    CirclePowerUsageRequest,
    CircleSwitchRelayRequest,
)
from ..messages.responses import (
    CircleCalibrationResponse,
    CircleClockResponse,
    CircleEnergyCountersResponse,
    CirclePowerUsageResponse,
    NodeAckLargeResponse,
)
from ..nodes import PlugwiseNode

_LOGGER = logging.getLogger(__name__)


class PlugwiseCircle(PlugwiseNode):
    """provides interface to the Plugwise Circle nodes and base class for Circle+ nodes"""

    def __init__(self, mac, address, message_sender):
        super().__init__(mac, address, message_sender)
        self._features = (
            FEATURE_ENERGY_CONSUMPTION_TODAY["id"],
            FEATURE_PING["id"],
            FEATURE_POWER_USE["id"],
            FEATURE_POWER_USE_LAST_8_SEC["id"],
            FEATURE_POWER_CONSUMPTION_CURRENT_HOUR["id"],
            FEATURE_POWER_CONSUMPTION_PREVIOUS_HOUR["id"],
            FEATURE_POWER_CONSUMPTION_TODAY["id"],
            FEATURE_POWER_CONSUMPTION_YESTERDAY["id"],
            FEATURE_POWER_PRODUCTION_CURRENT_HOUR["id"],
            # FEATURE_POWER_PRODUCTION_PREVIOUS_HOUR["id"],
            FEATURE_RSSI_IN["id"],
            FEATURE_RSSI_OUT["id"],
            FEATURE_RELAY["id"],
        )
        self._energy_consumption_today_reset = datetime.now().replace(
            hour=0, minute=0, second=0, microsecond=0
        )
        self._energy_history_collecting = False
        self._energy_history = {}
        self._energy_last_collected_timestamp = datetime(2000, 1, 1)
        self._energy_last_rollover_timestamp = datetime(2000, 1, 1)
        self._energy_last_local_hour = datetime.now().hour
        self._energy_last_populated_slot = 0
        self._energy_pulses_current_hour = None
        self._energy_pulses_prev_hour = None
        self._energy_rollover_day_started = False
        self._energy_rollover_day_finished = True
        self._energy_rollover_history_started = False
        self._energy_rollover_history_finished = True
        self._energy_rollover_hour_started = False
        self._energy_rollover_hour_finished = True
        self._energy_pulses_today_hourly = None
        self._energy_pulses_today_now = None
        self._energy_pulses_yesterday = None
        self._new_relay_state = False
        self._new_relay_stamp = datetime.now() - timedelta(seconds=MESSAGE_TIME_OUT)
        self._pulses_1s = None
        self._pulses_8s = None
        self._pulses_produced_1h = None
        self.calibration = False
        self._gain_a = None
        self._gain_b = None
        self._off_noise = None
        self._off_tot = None
        self._measures_power = True
        self._last_log_collected = False
        self.timezone_delta = datetime.now().replace(
            minute=0, second=0, microsecond=0
        ) - datetime.utcnow().replace(minute=0, second=0, microsecond=0)
        self._clock_offset = None
        self.get_clock(self.sync_clock)
        self._request_calibration()

    @property
    def current_power_usage(self):
        """
        Returns power usage during the last second in Watts
        Based on last received power usage information
        """
        if self._pulses_1s is not None:
            return self.pulses_to_kws(self._pulses_1s) * 1000
        return None

    @property
    def current_power_usage_8_sec(self):
        """
        Returns power usage during the last 8 second in Watts
        Based on last received power usage information
        """
        if self._pulses_8s is not None:
            return self.pulses_to_kws(self._pulses_8s, 8) * 1000
        return None

    @property
    def energy_consumption_today(self) -> float:
        """Returns total energy consumption since midnight in kWh"""
        if self._energy_pulses_today_now is not None:
            return self.pulses_to_kws(self._energy_pulses_today_now, 3600)
        return None

    @property
    def energy_consumption_today_last_reset(self):
        """Last reset of total energy consumption today"""
        return self._energy_consumption_today_reset

    @property
    def power_consumption_current_hour(self):
        """
        Returns the power usage during this running hour in kWh
        Based on last received power usage information
        """
        if self._energy_pulses_current_hour is not None:
            return self.pulses_to_kws(self._energy_pulses_current_hour, 3600)
        return None

    @property
    def power_consumption_previous_hour(self):
        """Returns power consumption during the previous hour in kWh"""
        if self._energy_pulses_prev_hour is not None:
            return self.pulses_to_kws(self._energy_pulses_prev_hour, 3600)
        return None

    @property
    def power_consumption_today(self):
        """Total power consumption during today in kWh"""
        if self._energy_pulses_today_hourly is not None:
            return self.pulses_to_kws(self._energy_pulses_today_hourly, 3600)
        return None

    @property
    def power_consumption_yesterday(self):
        """Total power consumption of yesterday in kWh"""
        if self._energy_pulses_yesterday is not None:
            return self.pulses_to_kws(self._energy_pulses_yesterday, 3600)
        return None

    @property
    def power_production_current_hour(self):
        """
        Returns the power production during this running hour in kWh
        Based on last received power usage information
        """
        if self._pulses_produced_1h is not None:
            return self.pulses_to_kws(self._pulses_produced_1h, 3600)
        return None

    @property
    def relay_state(self) -> bool:
        """
        Return last known relay state or the new switch state by anticipating
        the acknowledge for new state is getting in before message timeout.
        """
        if self._new_relay_stamp + timedelta(seconds=MESSAGE_TIME_OUT) > datetime.now():
            return self._new_relay_state
        return self._relay_state

    @relay_state.setter
    def relay_state(self, state):
        """Request the relay to switch state."""
        self._request_switch(state)
        self._new_relay_state = state
        self._new_relay_stamp = datetime.now()
        if state != self._relay_state:
            self.do_callback(FEATURE_RELAY["id"])

    def _request_calibration(self, callback=None):
        """Request calibration info"""
        self.message_sender(
            CircleCalibrationRequest(self._mac),
            callback,
            0,
            PRIORITY_HIGH,
        )

    def _request_switch(self, state, callback=None):
        """Request to switch relay state and request state info"""
        self.message_sender(
            CircleSwitchRelayRequest(self._mac, state),
            callback,
            0,
            PRIORITY_HIGH,
        )

    def request_power_update(self, callback=None):
        """Request power usage and update energy counters"""
        if self._available:
            self.message_sender(
                CirclePowerUsageRequest(self._mac),
                callback,
            )
            if len(self._energy_history) > 0:
                # Request new energy counters if last one is more than one hour ago
                if self._energy_last_collected_timestamp < datetime.utcnow().replace(
                    minute=0, second=0, microsecond=0
                ):
                    self.request_energy_counters()
            else:
                # No history collected yet, request energy history
                self.request_energy_counters()

    def message_for_circle(self, message):
        """
        Process received message
        """
        if isinstance(message, CirclePowerUsageResponse):
            if self.calibration:
                self._response_power_usage(message)
                _LOGGER.debug(
                    "Power update for %s, last update %s",
                    self.mac,
                    str(self._last_update),
                )
            else:
                _LOGGER.info(
                    "Received power update for %s before calibration information is known",
                    self.mac,
                )
                self._request_calibration(self.request_power_update)
        elif isinstance(message, NodeAckLargeResponse):
            self._node_ack_response(message)
        elif isinstance(message, CircleCalibrationResponse):
            self._response_calibration(message)
        elif isinstance(message, CircleEnergyCountersResponse):
            if self.calibration:
                self._response_energy_counters(message)
            else:
                _LOGGER.debug(
                    "Received power buffer log for %s before calibration information is known",
                    self.mac,
                )
                self._request_calibration(self.request_energy_counters)
        elif isinstance(message, CircleClockResponse):
            self._response_clock(message)
        else:
            self.message_for_circle_plus(message)

    def message_for_circle_plus(self, message):
        """Pass messages to PlugwiseCirclePlus class"""

    def _node_ack_response(self, message):
        """Process switch response message"""
        if message.ack_id == RELAY_SWITCHED_ON:
            if not self._relay_state:
                _LOGGER.debug(
                    "Switch relay on for %s",
                    self.mac,
                )
                self._relay_state = True
                self.do_callback(FEATURE_RELAY["id"])
        elif message.ack_id == RELAY_SWITCHED_OFF:
            if self._relay_state:
                _LOGGER.debug(
                    "Switch relay off for %s",
                    self.mac,
                )
                self._relay_state = False
                self.do_callback(FEATURE_RELAY["id"])
        else:
            _LOGGER.debug(
                "Unmanaged _node_ack_response %s received for %s",
                str(message.ack_id),
                self.mac,
            )

    def _response_power_usage(self, message: CirclePowerUsageResponse):
        # Sometimes the circle returns -1 for some of the pulse counters
        # likely this means the circle measures very little power and is suffering from
        # rounding errors. Zero these out. However, negative pulse values are valid
        # for power producing appliances, like solar panels, so don't complain too loudly.

        # Power consumption last second
        if message.pulse_1s.value == -1:
            message.pulse_1s.value = 0
            _LOGGER.debug(
                "1 sec power pulse counter for node %s has value of -1, corrected to 0",
                self.mac,
            )
        self._pulses_1s = message.pulse_1s.value
        if message.pulse_1s.value != 0:
            if message.nanosecond_offset.value != 0:
                pulses_1s = (
                    message.pulse_1s.value
                    * (1000000000 + message.nanosecond_offset.value)
                ) / 1000000000
            else:
                pulses_1s = message.pulse_1s.value
            self._pulses_1s = pulses_1s
        else:
            self._pulses_1s = 0
        self.do_callback(FEATURE_POWER_USE["id"])
        # Power consumption last 8 seconds
        if message.pulse_8s.value == -1:
            message.pulse_8s.value = 0
            _LOGGER.debug(
                "8 sec power pulse counter for node %s has value of -1, corrected to 0",
                self.mac,
            )
        if message.pulse_8s.value != 0:
            if message.nanosecond_offset.value != 0:
                pulses_8s = (
                    message.pulse_8s.value
                    * (1000000000 + message.nanosecond_offset.value)
                ) / 1000000000
            else:
                pulses_8s = message.pulse_8s.value
            self._pulses_8s = pulses_8s
        else:
            self._pulses_8s = 0
        self.do_callback(FEATURE_POWER_USE_LAST_8_SEC["id"])
        # Power consumption current hour
        if message.pulse_hour_consumed.value == -1:
            _LOGGER.debug(
                "1 hour consumption power pulse counter for node %s has value of -1, drop value",
                self.mac,
            )
        else:
            self._update_energy_current_hour(message.pulse_hour_consumed.value)

        # Power produced current hour
        if message.pulse_hour_produced.value == -1:
            message.pulse_hour_produced.value = 0
            _LOGGER.debug(
                "1 hour power production pulse counter for node %s has value of -1, corrected to 0",
                self.mac,
            )
        if self._pulses_produced_1h != message.pulse_hour_produced.value:
            self._pulses_produced_1h = message.pulse_hour_produced.value
            self.do_callback(FEATURE_POWER_PRODUCTION_CURRENT_HOUR["id"])

    def _response_calibration(self, message: CircleCalibrationResponse):
        """Store calibration properties"""
        for calibration in ("gain_a", "gain_b", "off_noise", "off_tot"):
            val = getattr(message, calibration).value
            setattr(self, "_" + calibration, val)
        self.calibration = True

    def pulses_to_kws(self, pulses, seconds=1):
        """
        converts the amount of pulses to kWs using the calaboration offsets
        """
        if pulses is None:
            return None
        if pulses == 0 or not self.calibration:
            return 0.0
        pulses_per_s = pulses / float(seconds)
        corrected_pulses = seconds * (
            (
                (((pulses_per_s + self._off_noise) ** 2) * self._gain_b)
                + ((pulses_per_s + self._off_noise) * self._gain_a)
            )
            + self._off_tot
        )
        calc_value = corrected_pulses / PULSES_PER_KW_SECOND / seconds
        # Fix minor miscalculations
        if -0.001 < calc_value < 0.001:
            calc_value = 0.0
        return calc_value

    def _collect_energy_pulses(self, start_utc: datetime, end_utc: datetime):
        """Return energy pulses of given hours"""

        if start_utc == end_utc:
            hours = 0
        else:
            hours = int((end_utc - start_utc).seconds / 3600)
        _energy_history_failed = False
        _energy_pulses = 0
        for hour in range(0, hours + 1):
            _log_timestamp = start_utc + timedelta(hours=hour)
            if self._energy_history.get(_log_timestamp) is not None:
                _energy_pulses += self._energy_history[_log_timestamp]
                _LOGGER.debug(
                    "_collect_energy_pulses for %s | %s : %s, total = %s",
                    self.mac,
                    str(_log_timestamp),
                    str(self._energy_history[_log_timestamp]),
                    str(_energy_pulses),
                )
            else:
                _mem_address = self._energy_timestamp_memory_address(_log_timestamp)
                _LOGGER.info(
                    "_collect_energy_pulses for %s at %s not found, request counter from memory %s (from mem=%s, slot=%s, timestamp=%s)",
                    self.mac,
                    str(_log_timestamp),
                    str(_mem_address),
                    str(self._last_log_address),
                    str(self._energy_last_populated_slot),
                    str(self._energy_last_collected_timestamp),
                )
                self.request_energy_counters(_mem_address)
                _energy_history_failed = True

        # Validate all history values where present
        if not _energy_history_failed:
            return _energy_pulses
        return None

    def _update_energy_current_hour(self, _pulses_cur_hour):
        """Update energy consumption (pulses) of current hour"""
        _LOGGER.info(
            "_update_energy_current_hour for %s | counter = %s, update= %s",
            self.mac,
            str(self._energy_pulses_current_hour),
            str(_pulses_cur_hour),
        )
        _hour_rollover = False
        if self._energy_pulses_current_hour is None:
            self._energy_pulses_current_hour = _pulses_cur_hour
            self.do_callback(FEATURE_POWER_CONSUMPTION_CURRENT_HOUR["id"])
        else:
            if self._energy_pulses_current_hour != _pulses_cur_hour:
                if self._energy_pulses_current_hour > _pulses_cur_hour:
                    _hour_rollover = True
                self._energy_pulses_current_hour = _pulses_cur_hour
                self.do_callback(FEATURE_POWER_CONSUMPTION_CURRENT_HOUR["id"])
            # Update today
            self._update_energy_today_now(_hour_rollover, False, False)

    def _update_energy_today_now(
        self, hour_rollover=False, history_rollover=False, day_rollover=False
    ):
        """Update energy consumption (pulses) of today up to now"""

        _pulses_today_now = None

        # Check for rollovers triggers
        if hour_rollover and self._energy_rollover_hour_finished:
            self._energy_rollover_hour_started = True
            self._energy_rollover_hour_finished = False
        if history_rollover and self._energy_rollover_history_finished:
            self._energy_rollover_history_started = True
            self._energy_rollover_history_finished = False
        if day_rollover and self._energy_rollover_day_finished:
            self._energy_rollover_day_started = True
            self._energy_rollover_day_finished = False

        # Set counter
        if self._energy_rollover_hour_started:
            if self._energy_rollover_history_started:
                if self._energy_rollover_day_started:
                    # Day rollover, reset to only current hour
                    _pulses_today_now = self._energy_pulses_current_hour
                    self._energy_rollover_day_started = False
                    self._energy_rollover_day_finished = True
                else:
                    # Hour rollover, reset to hour history with current hour
                    if (
                        self._energy_pulses_today_hourly is None
                        or self._energy_pulses_current_hour is None
                    ):
                        _pulses_today_now = None
                    else:
                        _pulses_today_now = (
                            self._energy_pulses_today_hourly
                            + self._energy_pulses_current_hour
                        )
                self._energy_rollover_hour_started = False
                self._energy_rollover_hour_finished = True
                self._energy_rollover_history_started = False
                self._energy_rollover_history_finished = True
            else:
                # Wait for history_rollover, keep current counter
                _pulses_today_now = None
        else:
            if self._energy_rollover_history_started:
                # Wait for hour_rollover, keep current counter
                _pulses_today_now = None
            else:
                # Regular update
                if (
                    self._energy_pulses_today_hourly is None
                    or self._energy_pulses_current_hour is None
                ):
                    _pulses_today_now = None
                else:
                    _pulses_today_now = (
                        self._energy_pulses_today_hourly
                        + self._energy_pulses_current_hour
                    )

        if _pulses_today_now is None:
            _LOGGER.info(
                "_update_energy_today_now for %s | skip update, hour: %s=%s=%s, history: %s=%s=%s, day: %s=%s=%s",
                self.mac,
                str(hour_rollover),
                str(self._energy_rollover_hour_started),
                str(self._energy_rollover_hour_finished),
                str(history_rollover),
                str(self._energy_rollover_history_started),
                str(self._energy_rollover_history_finished),
                str(day_rollover),
                str(self._energy_rollover_day_started),
                str(self._energy_rollover_day_finished),
            )
        else:
            _LOGGER.info(
                "_update_energy_today_now for %s | counter = %s, update= %s (%s + %s)",
                self.mac,
                str(self._energy_pulses_today_now),
                str(_pulses_today_now),
                str(self._energy_pulses_today_hourly),
                str(self._energy_pulses_current_hour),
            )
            if self._energy_pulses_today_now is None:
                self._energy_pulses_today_now = _pulses_today_now
                if self._energy_pulses_today_now is not None:
                    self.do_callback(FEATURE_ENERGY_CONSUMPTION_TODAY["id"])
            else:
                if self._energy_pulses_today_now != _pulses_today_now:
                    self._energy_pulses_today_now = _pulses_today_now
                    self.do_callback(FEATURE_ENERGY_CONSUMPTION_TODAY["id"])

    def _update_energy_previous_hour(self, prev_hour: datetime):
        """Update energy consumption (pulses) of previous hour"""
        _pulses_prev_hour = self._collect_energy_pulses(prev_hour, prev_hour)
        _LOGGER.info(
            "_update_energy_previous_hour for %s | counter = %s, update= %s, timestamp %s",
            self.mac,
            str(self._energy_pulses_yesterday),
            str(_pulses_prev_hour),
            str(prev_hour),
        )
        if self._energy_pulses_prev_hour is None:
            self._energy_pulses_prev_hour = _pulses_prev_hour
            if self._energy_pulses_prev_hour is not None:
                self.do_callback(FEATURE_POWER_CONSUMPTION_PREVIOUS_HOUR["id"])
        else:
            if self._energy_pulses_prev_hour != _pulses_prev_hour:
                self._energy_pulses_prev_hour = _pulses_prev_hour
                self.do_callback(FEATURE_POWER_CONSUMPTION_PREVIOUS_HOUR["id"])

    def _update_energy_yesterday(
        self, start_yesterday: datetime, end_yesterday: datetime
    ):
        """Update energy consumption (pulses) of yesterday"""
        _pulses_yesterday = self._collect_energy_pulses(start_yesterday, end_yesterday)
        _LOGGER.debug(
            "_update_energy_yesterday for %s | counter = %s, update= %s, range %s to %s",
            self.mac,
            str(self._energy_pulses_yesterday),
            str(_pulses_yesterday),
            str(start_yesterday),
            str(end_yesterday),
        )
        if self._energy_pulses_yesterday is None:
            self._energy_pulses_yesterday = _pulses_yesterday
            if self._energy_pulses_yesterday is not None:
                self.do_callback(FEATURE_POWER_CONSUMPTION_YESTERDAY["id"])
        else:
            if self._energy_pulses_yesterday != _pulses_yesterday:
                self._energy_pulses_yesterday = _pulses_yesterday
                self.do_callback(FEATURE_POWER_CONSUMPTION_YESTERDAY["id"])

    def _update_energy_today_hourly(self, start_today: datetime, end_today: datetime):
        """Update energy consumption (pulses) of today up to last hour"""
        if start_today > end_today:
            _pulses_today_hourly = 0
        else:
            _pulses_today_hourly = self._collect_energy_pulses(start_today, end_today)
        _LOGGER.info(
            "_update_energy_today_hourly for %s | counter = %s, update= %s, range %s to %s",
            self.mac,
            str(self._energy_pulses_today_hourly),
            str(_pulses_today_hourly),
            str(start_today),
            str(end_today),
        )
        if self._energy_pulses_today_hourly is None:
            self._energy_pulses_today_hourly = _pulses_today_hourly
            if self._energy_pulses_today_hourly is not None:
                self.do_callback(FEATURE_POWER_CONSUMPTION_TODAY["id"])
        else:
            if self._energy_pulses_today_hourly != _pulses_today_hourly:
                self._energy_pulses_today_hourly = _pulses_today_hourly
                self.do_callback(FEATURE_POWER_CONSUMPTION_TODAY["id"])

    def request_energy_counters(self, log_address=None, callback=None):
        """Request power log of specified address"""
        _LOGGER.debug(
            "request_energy_counters for %s of address %s", self.mac, str(log_address)
        )
        if log_address is None:
            log_address = self._last_log_address
        if log_address is not None:
            if len(self._energy_history) > 48 or self._energy_history_collecting:
                # Energy history already collected
                if (
                    log_address == self._last_log_address
                    and self._energy_last_populated_slot == 4
                ):
                    # Rollover of energy counter slot, get new memory address first
                    self._energy_last_populated_slot = 0
                    self._request_info(self.request_energy_counters)
                else:
                    # Request new energy counters
                    self.message_sender(
                        CircleEnergyCountersRequest(self._mac, log_address),
                        None,
                        0,
                        PRIORITY_LOW,
                    )
            else:
                # Collect energy counters of today and yesterday
                # Each request contains will return 4 hours, except last request

                # TODO: validate range of log_addresses
                self._energy_history_collecting = True
                for req_log_address in range(log_address - 13, log_address):
                    self.message_sender(
                        CircleEnergyCountersRequest(self._mac, req_log_address),
                        None,
                        0,
                        PRIORITY_LOW,
                    )
                self.message_sender(
                    CircleEnergyCountersRequest(self._mac, log_address),
                    callback,
                    0,
                    PRIORITY_LOW,
                )

    def _response_energy_counters(self, message: CircleEnergyCountersResponse):
        """
        Save historical energy information in local counters
        Each response message contains 4 log counters (slots)
        of the energy pulses collected during the previous hour of given timestamp
        """
        if message.logaddr.value == self._last_log_address:
            self._energy_last_populated_slot = 0

        # Collect energy history pulses from received log address
        # Store pulse in self._energy_history using the timestamp in UTC as index
        _utc_hour_timestamp = datetime.utcnow().replace(
            minute=0, second=0, microsecond=0
        )
        _local_midnight_timestamp = datetime.now().replace(
            hour=0, minute=0, second=0, microsecond=0
        )
        _local_hour = datetime.now().hour
        _utc_midnight_timestamp = _utc_hour_timestamp - timedelta(hours=_local_hour)
        _midnight_rollover = False
        _history_rollover = False

        for _slot in range(1, 5):
            _log_timestamp = getattr(message, "logdate%d" % (_slot,)).value
            if _log_timestamp is None:
                break
            self._energy_history[_log_timestamp] = getattr(
                message, "pulses%d" % (_slot,)
            ).value

            # Store last populated _slot
            if message.logaddr.value == self._last_log_address:
                self._energy_last_populated_slot = _slot

            # Store most recent timestamp of collected pulses
            if self._energy_last_collected_timestamp < _log_timestamp:
                self._energy_last_collected_timestamp = _log_timestamp

            # Trigger history rollover
            if (
                _log_timestamp == _utc_hour_timestamp
                and self._energy_last_rollover_timestamp < _utc_hour_timestamp
            ):
                self._energy_last_rollover_timestamp = _utc_hour_timestamp
                _history_rollover = True
                _LOGGER.info(
                    "_response_energy_counters for %s | history rollover, reset date to %s",
                    self.mac,
                    str(_utc_hour_timestamp),
                )

            # Trigger midnight rollover
            if (
                _log_timestamp == _utc_midnight_timestamp
                and self._energy_consumption_today_reset < _local_midnight_timestamp
            ):
                _LOGGER.info(
                    "_response_energy_counters for %s | midnight rollover, reset date to %s",
                    self.mac,
                    str(_local_midnight_timestamp),
                )
                self._energy_consumption_today_reset = _local_midnight_timestamp
                _midnight_rollover = True

        # Reset energy collection progress
        if (
            self._energy_history_collecting
            and len(self._energy_history) > 48
            and self._energy_last_collected_timestamp == _utc_hour_timestamp
        ):
            self._energy_last_rollover_timestamp = self._energy_last_collected_timestamp
            self._energy_history_collecting = False
            _history_rollover = False
            _midnight_rollover = False
        else:
            _LOGGER.info(
                "_response_energy_counters for %s | collection not running, len=%s, timestamp:%s=%s",
                self.mac,
                str(len(self._energy_history)),
                str(self._energy_last_collected_timestamp),
                str(_utc_hour_timestamp),
            )

        # Update energy counters
        if not self._energy_history_collecting:
            self._update_energy_previous_hour(_utc_hour_timestamp)
            self._update_energy_today_hourly(
                _utc_midnight_timestamp + timedelta(hours=1),
                _utc_hour_timestamp,
            )
            self._update_energy_yesterday(
                _utc_midnight_timestamp - timedelta(hours=23),
                _utc_midnight_timestamp,
            )
            self._update_energy_today_now(False, _history_rollover, _midnight_rollover)
        else:
            _LOGGER.info(
                "_response_energy_counters for %s | self._energy_history_collecting running",
                self.mac,
                str(_local_midnight_timestamp),
            )

        # Cleanup energy history for more than 8 day's ago
        _8_days_ago = datetime.utcnow().replace(
            minute=0, second=0, microsecond=0
        ) - timedelta(days=8)
        for log_timestamp in list(self._energy_history.keys()):
            if log_timestamp < _8_days_ago:
                del self._energy_history[log_timestamp]

    def _response_clock(self, message: CircleClockResponse):
        log_date = datetime(
            datetime.now().year,
            datetime.now().month,
            datetime.now().day,
            message.time.value.hour,
            message.time.value.minute,
            message.time.value.second,
        )
        clock_offset = message.timestamp.replace(microsecond=0) - (
            log_date + self.timezone_delta
        )
        if clock_offset.days == -1:
            self._clock_offset = clock_offset.seconds - 86400
        else:
            self._clock_offset = clock_offset.seconds
        _LOGGER.debug(
            "Clock of node %s has drifted %s sec",
            self.mac,
            str(self._clock_offset),
        )

    def get_clock(self, callback=None):
        """get current datetime of internal clock of Circle."""
        self.message_sender(
            CircleClockGetRequest(self._mac),
            callback,
            0,
            PRIORITY_LOW,
        )

    def set_clock(self, callback=None):
        """set internal clock of CirclePlus."""
        self.message_sender(
            CircleClockSetRequest(self._mac, datetime.utcnow()),
            callback,
        )

    def sync_clock(self, max_drift=0):
        """Resync clock of node if time has drifted more than MAX_TIME_DRIFT"""
        if self._clock_offset is not None:
            if max_drift == 0:
                max_drift = MAX_TIME_DRIFT
            if (self._clock_offset > max_drift) or (self._clock_offset < -(max_drift)):
                _LOGGER.info(
                    "Reset clock of node %s because time has drifted %s sec",
                    self.mac,
                    str(self._clock_offset),
                )
                self.set_clock()

    def _energy_timestamp_memory_address(self, utc_timestamp: datetime):
        """Return memory address for given energy counter timestamp"""
        _utc_now_timestamp = datetime.utcnow().replace(
            minute=0, second=0, microsecond=0
        )
        if utc_timestamp > _utc_now_timestamp:
            return None

        _seconds_offset = (_utc_now_timestamp - utc_timestamp).seconds
        _hours_offset = _seconds_offset / 3600

        _slot = self._energy_last_populated_slot
        if _slot == 0:
            _slot = 4
        _address = self._last_log_address

        # last known
        _hours = 1
        while _hours <= _hours_offset:
            _slot -= 1
            if _slot == 0:
                _address -= 1
                _slot = 4
            _hours += 1

        return _address

Plugwise Circle node object. provides interface to the Plugwise Circle nodes and base class for Circle+ nodes # FEATURE_POWER_PRODUCTION_PREVIOUS_HOUR["id"], Returns power usage during the last second in Watts         Based on last received power usage information Returns power usage during the last 8 second in Watts         Based on last received power usage information Returns total energy consumption since midnight in kWh Last reset of total energy consumption today Returns the power usage during this running hour in kWh         Based on last received power usage information Returns power consumption during the previous hour in kWh Total power consumption during today in kWh Total power consumption of yesterday in kWh Returns the power production during this running hour in kWh         Based on last received power usage information Return last known relay state or the new switch state by anticipating         the acknowledge for new state is getting in before message timeout. Request the relay to switch state. Request calibration info Request to switch relay state and request state info Request power usage and update energy counters # Request new energy counters if last one is more than one hour ago # No history collected yet, request energy history Process received message Pass messages to PlugwiseCirclePlus class Process switch response message # Sometimes the circle returns -1 for some of the pulse counters # likely this means the circle measures very little power and is suffering from # rounding errors. Zero these out. However, negative pulse values are valid # for power producing appliances, like solar panels, so don't complain too loudly. # Power consumption last second # Power consumption last 8 seconds # Power consumption current hour # Power produced current hour Store calibration properties converts the amount of pulses to kWs using the calaboration offsets # Fix minor miscalculations Return energy pulses of given hours # Validate all history values where present Update energy consumption (pulses) of current hour # Update today Update energy consumption (pulses) of today up to now # Check for rollovers triggers # Set counter # Day rollover, reset to only current hour # Hour rollover, reset to hour history with current hour # Wait for history_rollover, keep current counter # Wait for hour_rollover, keep current counter # Regular update Update energy consumption (pulses) of previous hour Update energy consumption (pulses) of yesterday Update energy consumption (pulses) of today up to last hour Request power log of specified address # Energy history already collected # Rollover of energy counter slot, get new memory address first # Request new energy counters # Collect energy counters of today and yesterday # Each request contains will return 4 hours, except last request # TODO: validate range of log_addresses Save historical energy information in local counters         Each response message contains 4 log counters (slots)         of the energy pulses collected during the previous hour of given timestamp # Collect energy history pulses from received log address # Store pulse in self._energy_history using the timestamp in UTC as index # Store last populated _slot # Store most recent timestamp of collected pulses # Trigger history rollover # Trigger midnight rollover # Reset energy collection progress # Update energy counters # Cleanup energy history for more than 8 day's ago get current datetime of internal clock of Circle. set internal clock of CirclePlus. Resync clock of node if time has drifted more than MAX_TIME_DRIFT Return memory address for given energy counter timestamp # last known