Skip to content

Commit

Permalink
feat(automower): improve algorythm and fix time management
Browse files Browse the repository at this point in the history
  • Loading branch information
XavierBerger committed Sep 19, 2023
1 parent 065e809 commit ced0eb2
Show file tree
Hide file tree
Showing 6 changed files with 70 additions and 136 deletions.
118 changes: 28 additions & 90 deletions appdaemon/apps/automower.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,6 @@ class Automower(hass.Hass):
def initialize(self):
"""
Initialize the Automower Automation.
This method sets up the necessary listeners and handles for the Automower Automation.
It registers a callback for monitoring the Automower's sensors and initializes
parameters for the automation.
Returns:
None
"""
self.log("Starting Automower Automation")

Expand All @@ -69,31 +62,22 @@ def initialize(self):
self.park_max_duration = self.get_state("number.nono_park_for", attribute="max")
self.log(f"\tpark max duration : {self.park_max_duration}")

# This sensor is not only give problem but also tell sheddule or if park until further notice has been activated
# This sensor tells if 'sheddule' or 'park until further notice' has been activated
self.listen_state(self.callback_automower_automation, "sensor.nono_problem_sensor", immediate=True)

####################################################################################################################
# UTILITIES
####################################################################################################################

def log_parked_because_of_rain(self):
"""
Log the status of the 'parked_because_of_rain' binary sensor.
This method logs the current state of the 'parked_because_of_rain' binary sensor.
Returns:
None
"""
self.log(f"\tbinary_sensor.parked_because_of_rain: {self.get_state('binary_sensor.parked_because_of_rain')}")

def send_notification(self, **kwargs):
"""
Send a notification.
This method sends a notification with the specified title and message.
Args:
kwargs: Keyword arguments containing the notification title and message.
Returns:
None
"""
self.log("Send notification")
self.log(f"\tMessage: {kwargs['message']}")
Expand All @@ -102,17 +86,6 @@ def send_notification(self, **kwargs):
def service(self, message, command, **kwargs):
"""
Call a service and send a notification.
This method calls a specified service with given keyword arguments and sends a notification
with a specified title and message.
Args:
message (str): The message for the notification.
command (str): The service command to call.
kwargs: Keyword arguments for the service call.
Returns:
None
"""
self.log("Call service")
self.log(f"\t{command} ({kwargs})")
Expand All @@ -122,15 +95,6 @@ def service(self, message, command, **kwargs):
def force_park(self, message, duration):
"""
Force the Automower to park for a specific duration.
This method triggers a service call to set the parking duration for the Automower.
Args:
message (str): The message for the notification.
duration (float): The duration for which the Automower should be parked.
Returns:
None
"""
self.service(
message=message,
Expand All @@ -142,12 +106,6 @@ def force_park(self, message, duration):
def restart_after_rain(self):
"""
Restart the Automower after a period of rain.
This method triggers a service call to restart the Automower and updates the state of the
'parked_because_of_rain' binary sensor.
Returns:
None
"""
self.service(
message=self.args["message_lawn_is_dry"],
Expand All @@ -156,26 +114,22 @@ def restart_after_rain(self):
)
self.set_state("binary_sensor.parked_because_of_rain", state="off")

####################################################################################################################
# APPLICATION MANAGEMENT
####################################################################################################################

def callback_automower_automation(self, entity, attribute, old, new, kwargs):
"""
Callback for automower automation activation.
This method is called when the Automower automation activation is triggered.
It handles different automation scenarios based on the new state value.
Args:
Arguments as define into Appdaemon callback documentation.
Returns:
None
"""
# self.log(f"new={new}")
if new == "parked_until_further_notice":
# Deregister callbacks
while len(self.state_handles) >= 1:
handle = self.state_handles.pop()
self.cancel_listen_state(handle)
message = self.args["message_deactivated"]
elif new == "week_schedule":
elif new in ["week_schedule", "charging"]:
if len(self.state_handles) != 0:
# callback are already registred. No need to register again
return
Expand All @@ -184,7 +138,7 @@ def callback_automower_automation(self, entity, attribute, old, new, kwargs):
# Listen for rain sensors
self.state_handles.append(self.listen_state(self.callback_rain_changed, "sensor.rain_last_6h"))

# Listen for sun start to decreass
# Listen for sun start to decrease
self.state_handles.append(
self.listen_state(self.callback_sun_is_at_top, "sun.sun", attribute="rising", new=False)
)
Expand All @@ -206,18 +160,13 @@ def callback_automower_automation(self, entity, attribute, old, new, kwargs):
self.log(f"\t{message}")
self.send_notification(message=message)

####################################################################################################################
# RAIN MANAGEMENT
####################################################################################################################

def callback_rain_changed(self, entity, attribute, old, new, kwargs):
"""
Callback for handling rain sensor changes.
This method is called when the rain sensor state changes. It handles different scenarios based
on the rain sensor values to control the Automower's behavior during rain.
Args:
Arguments as define into Appdaemon callback documentation.
Returns:
None
"""
self.log("Rain event triggered")
self.log_parked_because_of_rain()
Expand Down Expand Up @@ -263,15 +212,6 @@ def callback_rain_changed(self, entity, attribute, old, new, kwargs):
def callback_sun_is_at_top(self, entity, attribute, old, new, kwargs):
"""
Callback for handling sun position changes.
This method is called when the sun's position changes. It checks the state of the
'parked_because_of_rain' binary sensor and the rain sensor to determine the Automower's behavior.
Args:
Arguments as define into Appdaemon callback documentation.
Returns:
None
"""
self.log("Sun event triggered")
self.log_parked_because_of_rain()
Expand All @@ -287,23 +227,19 @@ def callback_sun_is_at_top(self, entity, attribute, old, new, kwargs):
self.log(f"\t{message}")
self.log_parked_because_of_rain()

####################################################################################################################
# SESSION MANAGEMENT
####################################################################################################################

def callback_next_start_changed(self, entity, attribute, old, new, kwargs):
"""
Callback for handling changes in the next start time.
This method is called when the next start time changes. It calculates the time difference
between the next start and the end of the mowing session to determine the appropriate action.
Args:
Arguments as define into Appdaemon callback documentation.
Returns:
None
"""
self.log("Next start event triggered")
if self.get_state("binary_sensor.parked_because_of_rain") == "on":
message = "Robot is parked because of rain. Nothing to check."
self.log(f"\t{message}")
self.send_notification(message=message, disable_notification=True)
return

self.log(f"\told={old}")
Expand All @@ -317,21 +253,23 @@ def callback_next_start_changed(self, entity, attribute, old, new, kwargs):
return

# Get next end of session
local = pytz.timezone("UTC")
mowing_session_end = datetime.strptime(
self.get_state("calendar.nono", attribute="end_time"), "%Y-%m-%d %H:%M:%S"
)

print(f"self.get_timezone() = {self.get_timezone()}")
local = pytz.timezone(self.get_timezone())
mowing_session_end_utc = local.localize(mowing_session_end, is_dst=None).astimezone(pytz.utc)

self.log(f"\tMowing session will end at {mowing_session_end_utc}")
self.log(f"\tMowing session will end at {mowing_session_end} => {mowing_session_end_utc} UTC")

# Get next start
next_start = datetime.strptime(new, "%Y-%m-%dT%H:%M:%S+00:00")
next_start_utc = local.localize(next_start, is_dst=None).astimezone(pytz.utc)
next_start_utc = datetime.strptime(new, "%Y-%m-%dT%H:%M:%S+00:00").replace(tzinfo=pytz.utc)
next_start = next_start_utc.astimezone(local)

# Check delta and decide action to perform
delta = (mowing_session_end_utc - next_start_utc).total_seconds() / 3600
self.log(f"\tNext start is planned at {next_start_utc}")
self.log(f"\tNext start is planned at {next_start} => {next_start_utc} UTC")
self.log(f"\tThe number of hour before mowing session end is {delta}")
if delta < 0:
message = f"Session completed. Lets restart tomorrow at {next_start}"
Expand Down
29 changes: 8 additions & 21 deletions appdaemon/test/appdaemon_testing/hass_driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,16 +56,15 @@ def __init__(self):
run_minutely=mock.Mock(),
set_state=mock.Mock(side_effect=self._se_set_state),
time=mock.Mock(),
get_timezone=mock.Mock(return_value="Europe/Paris"),
turn_off=mock.Mock(),
turn_on=mock.Mock(),
)

self._setup_active = False
self._states: Dict[str, Dict[str, Any]] = defaultdict(lambda: {"state": None})
self._events: Dict[str, Any] = defaultdict(lambda: [])
self._state_spys: Dict[Union[str, None], List[StateSpy]] = defaultdict(
lambda: []
)
self._state_spys: Dict[Union[str, None], List[StateSpy]] = defaultdict(lambda: [])
self._event_spys: Dict[str, EventSpy] = defaultdict(lambda: [])
self._run_in_simulations = []
self._clock_time = 0 # Simulated time in seconds
Expand All @@ -88,9 +87,7 @@ def inject_mocks(self) -> None:
try:
getattr(hass.Hass, meth_name)
except AttributeError as exception:
raise AttributeError(
"Attempt to mock non existing method: ", meth_name
) from exception
raise AttributeError("Attempt to mock non existing method: ", meth_name) from exception
_LOGGER.debug("Patching hass.Hass.%s", meth_name)
setattr(hass.Hass, meth_name, impl)

Expand Down Expand Up @@ -121,17 +118,13 @@ def test_my_app(hass_driver, my_app: MyApp):
yield None
self._setup_active = False

def _se_set_state(
self, entity_id: str, state, attribute_name="state", **kwargs: Optional[Any]
):
def _se_set_state(self, entity_id: str, state, attribute_name="state", **kwargs: Optional[Any]):
state_entry = self._states[entity_id]

# Update the state entry
state_entry[attribute_name] = state

def set_state(
self, entity, state, *, attribute_name="state", previous=None, trigger=None
) -> None:
def set_state(self, entity, state, *, attribute_name="state", previous=None, trigger=None) -> None:
"""
Update/set state of an entity.
Expand Down Expand Up @@ -194,14 +187,10 @@ def _se_get_state(self, entity_id=None, attribute="state", default=None, **kwarg

# With matched states, map the provided attribute (if applicable)
if attribute != "all":
matched_states = {
eid: state.get(attribute) for eid, state in matched_states.items()
}
matched_states = {eid: state.get(attribute) for eid, state in matched_states.items()}

if default is not None:
matched_states = {
eid: state or default for eid, state in matched_states.items()
}
matched_states = {eid: state or default for eid, state in matched_states.items()}

if fully_qualified:
return matched_states[entity_id]
Expand All @@ -213,9 +202,7 @@ def get_number_of_state_callbacks(self, entity):
return len(self._state_spys.get(entity))
return 0

def _se_listen_state(
self, callback, entity=None, attribute=None, new=None, old=None, **kwargs
) -> StateSpy:
def _se_listen_state(self, callback, entity=None, attribute=None, new=None, old=None, **kwargs) -> StateSpy:
spy = StateSpy(
callback=callback,
attribute=attribute or "state",
Expand Down
13 changes: 6 additions & 7 deletions appdaemon/test/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
anybadge==1.14.0
appdaemon==4.4.2
coverage==7.2.7
esphome==2023.8.0
pylint==2.17.5
pytest-random-order==1.1.0
pytest-watch==4.2.0
appdaemon
coverage
esphome
pylint
pytest-random-order
pytest-watch
7 changes: 7 additions & 0 deletions appdaemon/test/run_test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#!/bin/bash
test_dir=$(realpath "$(dirname $0)")
appdaemon_dir=$(realpath "${test_dir}/../")
apps_dir=$(realpath "${appdaemon_dir}/apps/")
pushd ${appdaemon_dir} > /dev/null
PYTHONPATH=${apps_dir} pytest-watch -- --sw --random-order -vs $@
popd > /dev/null
2 changes: 2 additions & 0 deletions appdaemon/test/test_automower_activation.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ def test__initialize__automation_activated(self, hass_driver, automower: Automow
mock.call("\tpark max duration : 60480"),
mock.call("Next start event triggered"),
mock.call("\tRobot is parked because of rain. Nothing to check."),
mock.call("Send notification"),
mock.call("\tMessage: Robot is parked because of rain. Nothing to check."),
mock.call("Automower automation activation triggered"),
mock.call("\tAdvanced automation is activated."),
mock.call("Send notification"),
Expand Down
Loading

0 comments on commit ced0eb2

Please sign in to comment.