Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Release Lazy.4 #269

Merged
merged 3 commits into from
Mar 28, 2023
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
2 changes: 1 addition & 1 deletion .github/workflows/pull.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ jobs:
run: |
pytest \
-qq \
--timeout=9 \
--timeout=20 \
--durations=10 \
-n auto \
--cov custom_components.meross_lan \
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/push.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ jobs:
run: |
pytest \
-qq \
--timeout=9 \
--timeout=20 \
--durations=10 \
-n auto \
--cov custom_components.meross_lan \
Expand Down
6 changes: 6 additions & 0 deletions custom_components/meross_lan/emulator/emulator.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ def __init__(self, descriptor: MerossEmulatorDescriptor, key):
self.update_epoch()
print(f"Initialized {descriptor.productname} (model:{descriptor.productmodel})")

def set_timezone(self, timezone: str):
# beware when using TZ names: here we expect a IANA zoneinfo key
# as "US/Pacific" or so. Using tzname(s) like "PDT" or "PST"
# such as those recovered from tzinfo.tzname() might be wrong
self.descriptor.timezone = self.descriptor.time[mc.KEY_TIMEZONE] = timezone

@property
def tzinfo(self):
tz_name = self.descriptor.timezone
Expand Down
Empty file.
72 changes: 44 additions & 28 deletions custom_components/meross_lan/emulator/mixins/electricity.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@


class ElectricityMixin(MerossEmulator if typing.TYPE_CHECKING else object):

# used to 'fix' and control the power level in tests
# if None (default) it will generate random values
_power_set: int | None = None
power: int

def __init__(self, descriptor: MerossEmulatorDescriptor, key):
super().__init__(descriptor, key)
self.payload_electricity = descriptor.namespaces[
Expand All @@ -20,6 +26,12 @@ def __init__(self, descriptor: MerossEmulatorDescriptor, key):
self.electricity = self.payload_electricity[mc.KEY_ELECTRICITY]
self.voltage_average: int = self.electricity[mc.KEY_VOLTAGE] or 2280
self.power = self.electricity[mc.KEY_POWER]
self._power_set = None

def set_power(self, power: int | None):
self._power_set = power
if power is not None:
self.power = self.electricity[mc.KEY_POWER] = power

def _GET_Appliance_Control_Electricity(self, header, payload):
"""
Expand All @@ -33,25 +45,25 @@ def _GET_Appliance_Control_Electricity(self, header, payload):
}
}
"""
p_electricity = self.electricity
power: int = p_electricity[mc.KEY_POWER] # power in mW
if randint(0, 5) == 0:
# make a big power step
power += randint(-1000000, 1000000)
else:
# make some noise
power += randint(-1000, 1000)

if power > 3600000:
p_electricity[mc.KEY_POWER] = self.power = 3600000
elif power < 0:
p_electricity[mc.KEY_POWER] = self.power = 0
if self._power_set is None:
if randint(0, 5) == 0:
# make a big power step
power = self.power + randint(-1000000, 1000000)
else:
# make some noise
power = self.power + randint(-1000, 1000)
if power > 3600000:
power = 3600000
elif power < 0:
power = 0
else:
p_electricity[mc.KEY_POWER] = self.power = int(power)
power = self._power_set

p_electricity = self.electricity
p_electricity[mc.KEY_POWER] = self.power = power
p_electricity[mc.KEY_VOLTAGE] = self.voltage_average + randint(-20, 20)
p_electricity[mc.KEY_CURRENT] = int(
10 * self.power / p_electricity[mc.KEY_VOLTAGE]
10 * power / p_electricity[mc.KEY_VOLTAGE]
)
return mc.METHOD_GETACK, self.payload_electricity

Expand All @@ -61,9 +73,6 @@ class ConsumptionMixin(MerossEmulator if typing.TYPE_CHECKING else object):
# this is a static default but we're likely using
# the current 'power' state managed by the ElectricityMixin
power = 0.0 # in mW
energy = 0.0 # in Wh
epoch_prev: int
power_prev = 0.0

BUG_RESET = True

Expand All @@ -90,11 +99,13 @@ def _get_timestamp(consumptionx_item):
self.payload_consumptionx[mc.KEY_CONSUMPTIONX] = p_consumptionx

self.consumptionx = p_consumptionx
self.epoch_prev = self.epoch
self._epoch_prev = self.epoch
self._power_prev = None
self._energy_fraction = 0.0 # in Wh
# REMOVE
# "Asia/Bangkok" GMT + 7
# "Asia/Baku" GMT + 4
descriptor.timezone = descriptor.time[mc.KEY_TIMEZONE] = "Asia/Baku"
self.set_timezone("Asia/Baku")

def _GET_Appliance_Control_ConsumptionX(self, header, payload):
"""
Expand All @@ -108,17 +119,22 @@ def _GET_Appliance_Control_ConsumptionX(self, header, payload):
}
"""
# energy will be reset every time we update our consumptionx array
self.energy += (
(self.power + self.power_prev) * (self.epoch - self.epoch_prev) / 7200000
)
self.epoch_prev = self.epoch
self.power_prev = self.power
if self._power_prev is None:
self._energy_fraction += (
self.power * (self.epoch - self._epoch_prev) / 3600000
)
else:
self._energy_fraction += (
(self.power + self._power_prev) * (self.epoch - self._epoch_prev) / 7200000
)
self._epoch_prev = self.epoch
self._power_prev = self.power

if self.energy < 1.0:
if self._energy_fraction < 1.0:
return mc.METHOD_GETACK, self.payload_consumptionx

energy = int(self.energy)
self.energy -= energy
energy = int(self._energy_fraction)
self._energy_fraction -= energy

y, m, d, hh, mm, ss, weekday, jday, dst = gmtime(self.epoch)
ss = min(ss, 59) # clamp out leap seconds if the platform has them
Expand Down
14 changes: 13 additions & 1 deletion custom_components/meross_lan/meross_device.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import os
import socket
import asyncio
from time import strftime, time
from time import gmtime, strftime, time
from datetime import datetime, timezone, tzinfo
from zoneinfo import ZoneInfo
from uuid import uuid4
Expand Down Expand Up @@ -330,6 +330,18 @@ def name(self) -> str:
def online(self):
return self._online

def get_datetime(self, epoch):
"""
given the epoch (utc timestamp) returns the datetime
in device local timezone
"""
y, m, d, hh, mm, ss, weekday, jday, dst = gmtime(epoch)
ss = min(ss, 59) # clamp out leap seconds if the platform has them
devtime_utc = datetime(y, m, d, hh, mm, ss, 0, timezone.utc)
if (tz := self.tzinfo) is timezone.utc:
return devtime_utc
return devtime_utc.astimezone(tz)

def receive(self, header: dict, payload: dict, protocol) -> bool:
"""
default (received) message handling entry point
Expand Down
99 changes: 57 additions & 42 deletions custom_components/meross_lan/sensor.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from __future__ import annotations
import typing
from logging import DEBUG, WARNING
from time import gmtime
from time import time
from datetime import datetime, timedelta

from homeassistant.components.sensor import (
Expand Down Expand Up @@ -278,9 +278,8 @@ def _handle_Appliance_Control_Electricity(self, header: dict, payload: dict):
# dt = self.lastupdate - self._electricity_lastupdate
# de = (((last_power + power) / 2) * dt) / 3600
de = (
(last_power + power) *
(self.lastupdate - self._electricity_lastupdate)
) / 7200
(last_power + power) * (self.lastupdate - self._electricity_lastupdate)
) / 7200
self._consumption_estimate += de
self._sensor_energy_estimate.update_estimate(de)

Expand Down Expand Up @@ -309,7 +308,7 @@ def _schedule_next_reset(self, _now: datetime):
hour=0,
minute=0,
second=0,
microsecond=1,
microsecond=0,
tzinfo=dt_util.DEFAULT_TIME_ZONE,
)
self._cancel_energy_reset = async_track_point_in_time(
Expand Down Expand Up @@ -352,16 +351,12 @@ class ConsumptionSensor(MLSensor):
ATTR_RESET_TS = "reset_ts"
reset_ts: int = 0

_attr_state: int = 0
_attr_state: int | None

def __init__(self, device: MerossDevice):
self._attr_extra_state_attributes = {}
super().__init__(device, None, DEVICE_CLASS_ENERGY, DEVICE_CLASS_ENERGY, None)

@property
def available(self):
return True

@property
def state_class(self):
return STATE_CLASS_TOTAL_INCREASING
Expand All @@ -377,14 +372,28 @@ async def async_added_to_hass(self):
# device reading data). If an entity is disabled on startup of course our state
# will start resetted and our sums will restart (disabled means not interesting
# anyway)
if (self._attr_state != 0) or self._attr_extra_state_attributes:
if (self._attr_state is not None) or self._attr_extra_state_attributes:
return

try:
state = await get_entity_last_state_available(self.hass, self.entity_id)
if state is None:
return

# check if the restored sample is fresh enough i.e. it was
# updated after the device midnight for today..else it is too
# old to be good. Since we don't have actual device epoch we
# 'guess' it is nicely synchronized so we'll use our time
devicetime = self.device.get_datetime(time())
devicetime_today_midnight = datetime(
devicetime.year,
devicetime.month,
devicetime.day,
tzinfo=devicetime.tzinfo,
)
if state.last_updated < devicetime_today_midnight:
return

# fix beta/preview attr names (sometime REMOVE)
if "energy_offset" in state.attributes:
_attr_value = state.attributes["energy_offset"]
Expand All @@ -401,7 +410,13 @@ async def async_added_to_hass(self):
self._attr_extra_state_attributes[_attr_name] = _attr_value
# we also set the value as an instance attr for faster access
setattr(self, _attr_name, _attr_value)
self._attr_state = int(state.state)
# HA adds decimals when the display precision is set for the entity
# according to this issue #268. In order to try not mess statistics
# we're reverting to the old design where the sensor state is
# reported as 'unavailable' when the device is disconnected and so
# we don't restore the state value at all but just wait for a 'fresh'
# consumption value from the device. The attributes restoration will
# instead keep patching the 'consumption reset bug'
except Exception as e:
self.device.log(
WARNING,
Expand All @@ -411,12 +426,20 @@ async def async_added_to_hass(self):
str(e),
)

def set_unavailable(self):
# we need to preserve our state so we don't reset
# it on disconnection. Also, it's nice to have it
# available since this entity has a computed value
# not directly related to actual connection state
pass
def reset_consumption(self):
if self._attr_state != 0:
self._attr_state = 0
self._attr_extra_state_attributes = {}
self.offset = 0
self.reset_ts = 0
if self._hass_connected:
self.async_write_ha_state()
self.device.log(
DEBUG,
0,
"ConsumptionSensor(%s): no readings available for new day - resetting",
self.name,
)


class ConsumptionMixin(
Expand Down Expand Up @@ -454,15 +477,12 @@ def _handle_Appliance_Control_ConsumptionX(self, header: dict, payload: dict):
# against it's own midnight and we'll see a delayed 'sawtooth'
if self.device_timestamp > self._tomorrow_midnight_epoch:
# catch the device starting a new day since our last update (yesterday)
y, m, d, hh, mm, ss, weekday, jday, dst = gmtime(self.device_timestamp)
ss = min(ss, 59) # clamp out leap seconds if the platform has them
devtime_utc = datetime(y, m, d, hh, mm, ss, 0, dt_util.UTC)
devtime_devlocaltz = devtime_utc.astimezone(self.tzinfo)
devtime = self.get_datetime(self.device_timestamp)
devtime_today_midnight = datetime(
devtime_devlocaltz.year,
devtime_devlocaltz.month,
devtime_devlocaltz.day,
tzinfo=self.tzinfo,
devtime.year,
devtime.month,
devtime.day,
tzinfo=devtime.tzinfo,
)
# we'd better not trust our cached tomorrow, today and yesterday
# epochs (even if 99% of the times they should be good)
Expand Down Expand Up @@ -497,6 +517,7 @@ def _handle_Appliance_Control_ConsumptionX(self, header: dict, payload: dict):
if day[mc.KEY_TIME] >= self._yesterday_midnight_epoch
]
if (days_len := len(days)) == 0:
self._sensor_consumption.reset_consumption()
return

elif days_len > 1:
Expand All @@ -506,7 +527,6 @@ def _get_timestamp(day):

days = sorted(days, key=_get_timestamp)

_sensor_consumption = self._sensor_consumption
day_last: dict = days[-1]
day_last_time: int = day_last[mc.KEY_TIME]

Expand All @@ -515,24 +535,13 @@ def _get_timestamp(day):
# should start a new cycle but the consumption is too low
# (device starts reporting from 1 wh....) so, even if
# new day has come, new data have not
if self._consumption_last_value is not None:
self._consumption_last_value = None
_sensor_consumption._attr_state = 0
_sensor_consumption._attr_extra_state_attributes = {}
_sensor_consumption.offset = 0
_sensor_consumption.reset_ts = 0
if _sensor_consumption._hass_connected:
_sensor_consumption.async_write_ha_state()
self.log(
DEBUG,
0,
"ConsumptionMixin(%s): no readings available for new day - resetting",
self.name,
)
self._consumption_last_value = None
self._sensor_consumption.reset_consumption()
return

# now day_last 'should' contain today data in HA time.
day_last_value: int = day_last[mc.KEY_VALUE]
_sensor_consumption = self._sensor_consumption
# check if the device tripped its own midnight and started a
# new day readings
if days_len > 1 and (
Expand Down Expand Up @@ -581,7 +590,13 @@ def _get_timestamp(day):
)

elif day_last_value == self._consumption_last_value:
# no change in consumption..skip updating
# no change in consumption..skip updating unless sensor was disconnected
if _sensor_consumption._attr_state is None:
_sensor_consumption._attr_state = (
day_last_value - _sensor_consumption.offset
)
if _sensor_consumption._hass_connected:
_sensor_consumption.async_write_ha_state()
return

self._consumption_last_time = day_last_time
Expand Down
Loading