Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2f40f81ba | ||
|
|
c21291e9dd | ||
|
|
dd4335c811 | ||
|
|
6d2fd26f66 | ||
|
|
a734b5f780 | ||
|
|
486956cd1f | ||
|
|
0e64711c4f | ||
|
|
9bead746e8 |
No files matched your search
+1
-1
Submodule apps/ad_toolbox updated: ece851f5a0...5ae930c710.
+43
-44
@@ -37,9 +37,8 @@ class AlarmClock(SmartObject):
|
|||||||
self.switch_alarmclock_secondary = self.args['switch_alarmclock_secondary']
|
self.switch_alarmclock_secondary = self.args['switch_alarmclock_secondary']
|
||||||
self.inputdate_alarmclock_main = self.args['inputdate_alarmclock_main']
|
self.inputdate_alarmclock_main = self.args['inputdate_alarmclock_main']
|
||||||
self.inputdate_alarmclock_secondary = self.args['inputdate_alarmclock_secondary']
|
self.inputdate_alarmclock_secondary = self.args['inputdate_alarmclock_secondary']
|
||||||
self.input_text_wakeup_time = self.args['input_text_wakeup_time']
|
|
||||||
self.output_workday_sensor = self.args['output_workday_sensor']
|
self.output_workday_sensor = self.args['output_workday_sensor']
|
||||||
#self.sensor_wakeup_datetime = self.args['sensor_wakeup_datetime']
|
self.sensor_wakeup_datetime_sensor = self.args['sensor_wakeup_datetime']
|
||||||
self.sleeping_condition = SmartCondition.Evaluator(self, self.args['sleeping_condition'], condition_name="sleeping_condition", constants=self.constants, templates_library=self.templates_library)
|
self.sleeping_condition = SmartCondition.Evaluator(self, self.args['sleeping_condition'], condition_name="sleeping_condition", constants=self.constants, templates_library=self.templates_library)
|
||||||
|
|
||||||
self.lock = Lock()
|
self.lock = Lock()
|
||||||
@@ -54,11 +53,8 @@ class AlarmClock(SmartObject):
|
|||||||
|
|
||||||
self.run_hourly(self.on_refresh_cal,datetime.time(0, 0, 0))
|
self.run_hourly(self.on_refresh_cal,datetime.time(0, 0, 0))
|
||||||
|
|
||||||
self.sensor_wakeup_datetime_entity = self.create_entity("sensor.wakeup_datetime", icon="mdi:alarm")
|
self.sensor_wakeup_datetime_entity = self.create_entity(self.sensor_wakeup_datetime_sensor, icon="mdi:alarm")
|
||||||
self.workday_entity = self.create_entity(self.output_workday_sensor, icon="mdi:briefcase-check")
|
self.workday_entity = self.create_entity(self.output_workday_sensor, icon="mdi:briefcase-check")
|
||||||
#sensor_wakeup_datetime_entity = self.get_entity(self.sensor_wakeup_datetime)
|
|
||||||
#if not sensor_wakeup_datetime_entity.exists():
|
|
||||||
# sensor_wakeup_datetime_entity.add()
|
|
||||||
|
|
||||||
self.calendar = list()
|
self.calendar = list()
|
||||||
self.refresh_cal()
|
self.refresh_cal()
|
||||||
@@ -67,16 +63,17 @@ class AlarmClock(SmartObject):
|
|||||||
self.update_wakeup_time()
|
self.update_wakeup_time()
|
||||||
|
|
||||||
def refresh_cal(self):
|
def refresh_cal(self):
|
||||||
new_cal = list()
|
with self.log_group("refresh_cal"):
|
||||||
#if self.load_cal_from_google_calendar(new_cal):
|
new_cal = list()
|
||||||
if self.load_cal_from_ics(new_cal):
|
#if self.load_cal_from_google_calendar(new_cal):
|
||||||
if not self.are_cal_identical(self.calendar,new_cal):
|
if self.load_cal_from_ics(new_cal):
|
||||||
self.calendar = new_cal
|
if not self.are_cal_identical(self.calendar,new_cal):
|
||||||
self.log("Calendar has been updated!")
|
self.calendar = new_cal
|
||||||
self.print_cal(self.calendar)
|
self.log_info("Calendar has been updated!")
|
||||||
return True
|
self.print_cal(self.calendar)
|
||||||
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def are_cal_identical(self,calendar1,calendar2):
|
def are_cal_identical(self,calendar1,calendar2):
|
||||||
if len(calendar1) != len(calendar2): return False
|
if len(calendar1) != len(calendar2): return False
|
||||||
@@ -89,20 +86,20 @@ class AlarmClock(SmartObject):
|
|||||||
|
|
||||||
def print_cal(self,calendar):
|
def print_cal(self,calendar):
|
||||||
for event in calendar:
|
for event in calendar:
|
||||||
self.log(f"{event}")
|
self.log_info(f"{event}")
|
||||||
|
|
||||||
def load_cal_from_ics(self,calendar):
|
def load_cal_from_ics(self,calendar):
|
||||||
calendar.clear()
|
calendar.clear()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.log(f"Retrieving calengar from outlook.office365.com")
|
self.log_info(f"Retrieving calengar from outlook.office365.com")
|
||||||
r = get("https://outlook.office365.com/owa/calendar/0c64359c70eb41cd9713fe92c470d3df@ubisoft.com/5edb3fb5150f4203b66d9cf8db3184be11626033572842691749/calendar.ics", timeout=10)#, headers=headers, verify=False)
|
r = get("https://outlook.office365.com/owa/calendar/0c64359c70eb41cd9713fe92c470d3df@ubisoft.com/5edb3fb5150f4203b66d9cf8db3184be11626033572842691749/calendar.ics", timeout=10)#, headers=headers, verify=False)
|
||||||
#self.log(f"Processing Calendar")
|
#self.log_info(f"Processing Calendar")
|
||||||
|
|
||||||
today = datetime.datetime.combine(datetime.date.today(), datetime.time(0,0, 0))
|
today = datetime.datetime.combine(datetime.date.today(), datetime.time(0,0, 0))
|
||||||
start_load_date = today - datetime.timedelta(days=1)
|
start_load_date = today - datetime.timedelta(days=1)
|
||||||
end_load_date = today + datetime.timedelta(days=2)
|
end_load_date = today + datetime.timedelta(days=2)
|
||||||
#self.log("[" + str(start_load_date) + "]-[" + str(end_load_date) + "]")
|
#self.log_info("[" + str(start_load_date) + "]-[" + str(end_load_date) + "]")
|
||||||
|
|
||||||
calendar_ubi = icalendar.Calendar.from_ical(r.text)
|
calendar_ubi = icalendar.Calendar.from_ical(r.text)
|
||||||
for component in calendar_ubi.walk():
|
for component in calendar_ubi.walk():
|
||||||
@@ -111,7 +108,7 @@ class AlarmClock(SmartObject):
|
|||||||
if event_summary == "Away" or event_summary == "Absent(e)": event_type = EventType.Vacation
|
if event_summary == "Away" or event_summary == "Absent(e)": event_type = EventType.Vacation
|
||||||
elif event_summary == "Busy" or event_summary == "Occupé(e)": event_type = EventType.Busy
|
elif event_summary == "Busy" or event_summary == "Occupé(e)": event_type = EventType.Busy
|
||||||
elif event_summary == "Tentative" or event_summary == "Provisoire": event_type = EventType.Tentative
|
elif event_summary == "Tentative" or event_summary == "Provisoire": event_type = EventType.Tentative
|
||||||
elif event_summary == "Working elsewhere" or event_summary == "Free": event_type = EventType.Flex
|
elif event_summary == "Working elsewhere" or event_summary == "Free" or event_summary == "Disponible": event_type = EventType.Flex
|
||||||
else: event_type = None
|
else: event_type = None
|
||||||
|
|
||||||
start_datetime = component.get('dtstart').dt
|
start_datetime = component.get('dtstart').dt
|
||||||
@@ -138,16 +135,16 @@ class AlarmClock(SmartObject):
|
|||||||
if not outside_of_loading_period:
|
if not outside_of_loading_period:
|
||||||
if event_type:
|
if event_type:
|
||||||
calendar.append(CalendarEvent(start_datetime,end_datetime,event_type))
|
calendar.append(CalendarEvent(start_datetime,end_datetime,event_type))
|
||||||
else: self.log(f"[Unknow event : {event_summary}]Start at {start_datetime},Finish at {end_datetime}",level = "WARNING")
|
else: self.log_warning(f"[Unknow event : {event_summary}]Start at {start_datetime},Finish at {end_datetime}")
|
||||||
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
self.log_error("Couldn't load calendar")
|
self.log_error("Couldn't load calendar")
|
||||||
return False
|
return False
|
||||||
except (requests.exceptions.ConnectionError,requests.exceptions.ReadTimeout):
|
except (requests.exceptions.ConnectionError,requests.exceptions.ReadTimeout):
|
||||||
self.log("Couldn't load calendar, request Timeout",level = "WARNING")
|
self.log_warning("Couldn't load calendar, request Timeout")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
#self.log(f"Calendar loaded")
|
#self.log_info(f"Calendar loaded")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def on_alarm_clock_set(self, entity, attribute, old, new, kwargs):
|
def on_alarm_clock_set(self, entity, attribute, old, new, kwargs):
|
||||||
@@ -160,10 +157,11 @@ class AlarmClock(SmartObject):
|
|||||||
self.update_wakeup_time()
|
self.update_wakeup_time()
|
||||||
|
|
||||||
def update_workday_status(self):
|
def update_workday_status(self):
|
||||||
is_workday = self.is_wakeup_time_on_a_working_day(datetime.datetime.now())
|
with self.log_group("update_workday_status"):
|
||||||
new_state = "on" if is_workday else "off"
|
is_workday = self.is_wakeup_time_on_a_working_day(datetime.datetime.now())
|
||||||
if self.workday_entity.get_state() != new_state:
|
new_state = "on" if is_workday else "off"
|
||||||
self.workday_entity.set_state(state=new_state)
|
if self.workday_entity.get_state() != new_state:
|
||||||
|
self.workday_entity.set_state(state=new_state)
|
||||||
|
|
||||||
def on_program_next_wakeup(self, kwargs):
|
def on_program_next_wakeup(self, kwargs):
|
||||||
self.handle_program_next_wakueup_cb = None
|
self.handle_program_next_wakueup_cb = None
|
||||||
@@ -172,12 +170,12 @@ class AlarmClock(SmartObject):
|
|||||||
|
|
||||||
#le reveil principal se reactive chaque matin
|
#le reveil principal se reactive chaque matin
|
||||||
if self.get_state(self.switch_alarmclock_main) == 'off':
|
if self.get_state(self.switch_alarmclock_main) == 'off':
|
||||||
self.log("Turning on main alarm clock")
|
self.log_info("Turning on main alarm clock")
|
||||||
no_switch_changed = False
|
no_switch_changed = False
|
||||||
self.turn_on(self.switch_alarmclock_main)
|
self.turn_on(self.switch_alarmclock_main)
|
||||||
#le reveil secondaire ne sonne qu'une fois, il est donc desactivé chaque matin
|
#le reveil secondaire ne sonne qu'une fois, il est donc desactivé chaque matin
|
||||||
if self.get_state(self.switch_alarmclock_secondary) == 'on':
|
if self.get_state(self.switch_alarmclock_secondary) == 'on':
|
||||||
self.log("Turning off secondary alarm clock")
|
self.log_info("Turning off secondary alarm clock")
|
||||||
no_switch_changed = False
|
no_switch_changed = False
|
||||||
self.turn_off(self.switch_alarmclock_secondary)
|
self.turn_off(self.switch_alarmclock_secondary)
|
||||||
|
|
||||||
@@ -196,7 +194,7 @@ class AlarmClock(SmartObject):
|
|||||||
|
|
||||||
wakeup_message = "On se reveil dans " + str(wakeup_in) + " minutes"
|
wakeup_message = "On se reveil dans " + str(wakeup_in) + " minutes"
|
||||||
|
|
||||||
self.log(wakeup_message)
|
self.log_info(wakeup_message)
|
||||||
if self.sleeping_condition.evaluate() == SmartCondition.Result.Succeeded: self.fire_event("WAKEUP_IN", wakeup_in = wakeup_in)
|
if self.sleeping_condition.evaluate() == SmartCondition.Result.Succeeded: self.fire_event("WAKEUP_IN", wakeup_in = wakeup_in)
|
||||||
#self.call_service("notify/ios_iphone_de_pierre", title = "Reveil", message = wakeup_message)
|
#self.call_service("notify/ios_iphone_de_pierre", title = "Reveil", message = wakeup_message)
|
||||||
|
|
||||||
@@ -205,7 +203,7 @@ class AlarmClock(SmartObject):
|
|||||||
|
|
||||||
wakeup_message = "WAKE UP!!!! It's " + self.wakeup_time_str
|
wakeup_message = "WAKE UP!!!! It's " + self.wakeup_time_str
|
||||||
|
|
||||||
self.log(wakeup_message)
|
self.log_info(wakeup_message)
|
||||||
if self.sleeping_condition.evaluate() == SmartCondition.Result.Succeeded: self.fire_event("WAKEUP")
|
if self.sleeping_condition.evaluate() == SmartCondition.Result.Succeeded: self.fire_event("WAKEUP")
|
||||||
#self.call_service("notify/ios_iphone_de_pierre", title = "Reveil", message = wakeup_message)
|
#self.call_service("notify/ios_iphone_de_pierre", title = "Reveil", message = wakeup_message)
|
||||||
|
|
||||||
@@ -248,11 +246,11 @@ class AlarmClock(SmartObject):
|
|||||||
wake_up_datetime = busy_event.start_time - datetime.timedelta(minutes=30)
|
wake_up_datetime = busy_event.start_time - datetime.timedelta(minutes=30)
|
||||||
new_wake_up_time = wake_up_datetime.strftime("%H:%M")
|
new_wake_up_time = wake_up_datetime.strftime("%H:%M")
|
||||||
|
|
||||||
self.log(f"A meeting have been found at {busy_event.start_time}, we'll wake up at {new_wake_up_time}")
|
self.log_info(f"A meeting have been found at {busy_event.start_time}, we'll wake up at {new_wake_up_time}")
|
||||||
return new_wake_up_time
|
return new_wake_up_time
|
||||||
|
|
||||||
new_wake_up_time = wakeup_datetime.strftime("%H:%M")
|
new_wake_up_time = wakeup_datetime.strftime("%H:%M")
|
||||||
#self.log(f"No meeting have been found before {wake_up_time}, we'll wake up at {wake_up_time}")
|
#self.log_info(f"No meeting have been found before {wake_up_time}, we'll wake up at {wake_up_time}")
|
||||||
return new_wake_up_time
|
return new_wake_up_time
|
||||||
|
|
||||||
def calculate_wakeup_time(self):
|
def calculate_wakeup_time(self):
|
||||||
@@ -266,10 +264,10 @@ class AlarmClock(SmartObject):
|
|||||||
new_wakeup_time = self.get_state(self.inputdate_alarmclock_main)[0:5]
|
new_wakeup_time = self.get_state(self.inputdate_alarmclock_main)[0:5]
|
||||||
new_wakeup_datetime = self.generate_wakeup_datetime_from_string(new_wakeup_time)
|
new_wakeup_datetime = self.generate_wakeup_datetime_from_string(new_wakeup_time)
|
||||||
if not self.is_wakeup_time_on_a_working_day(new_wakeup_datetime):
|
if not self.is_wakeup_time_on_a_working_day(new_wakeup_datetime):
|
||||||
self.log(f"Primary Wake up time ({new_wakeup_datetime}) is not on a working day")
|
self.log_info(f"Primary Wake up time ({new_wakeup_datetime}) is not on a working day")
|
||||||
new_wakeup_time = "--:--"
|
new_wakeup_time = "--:--"
|
||||||
elif self.is_wakeup_time_on_a_flex_day(max_flex_wakeup_datetime):
|
elif self.is_wakeup_time_on_a_flex_day(max_flex_wakeup_datetime):
|
||||||
self.log(f"Flex Wake up time ({max_flex_wakeup_datetime}) is on a flex office day")
|
self.log_info(f"Flex Wake up time ({max_flex_wakeup_datetime}) is on a flex office day")
|
||||||
new_wakeup_time = self.offset_wakeup_time_for_meeting(max_flex_wakeup_datetime)
|
new_wakeup_time = self.offset_wakeup_time_for_meeting(max_flex_wakeup_datetime)
|
||||||
else:
|
else:
|
||||||
new_wakeup_time = self.offset_wakeup_time_for_meeting(new_wakeup_datetime)
|
new_wakeup_time = self.offset_wakeup_time_for_meeting(new_wakeup_datetime)
|
||||||
@@ -290,15 +288,15 @@ class AlarmClock(SmartObject):
|
|||||||
with self.lock:
|
with self.lock:
|
||||||
#first, cancel all previous callbacks
|
#first, cancel all previous callbacks
|
||||||
if self.handle_program_next_wakueup_cb:
|
if self.handle_program_next_wakueup_cb:
|
||||||
self.log("Canceling previous program next wakeup cb")
|
self.log_info("Canceling previous program next wakeup cb")
|
||||||
self.cancel_timer(self.handle_program_next_wakueup_cb)
|
self.cancel_timer(self.handle_program_next_wakueup_cb)
|
||||||
self.handle_program_next_wakueup_cb = None
|
self.handle_program_next_wakueup_cb = None
|
||||||
if self.handle_prewakueup_cb:
|
if self.handle_prewakueup_cb:
|
||||||
self.log("Canceling previous pre wakeup cb")
|
self.log_info("Canceling previous pre wakeup cb")
|
||||||
self.cancel_timer(self.handle_prewakueup_cb)
|
self.cancel_timer(self.handle_prewakueup_cb)
|
||||||
self.handle_prewakueup_cb = None
|
self.handle_prewakueup_cb = None
|
||||||
if self.handle_wakueup_cb:
|
if self.handle_wakueup_cb:
|
||||||
self.log("Canceling previous wakeup cb")
|
self.log_info("Canceling previous wakeup cb")
|
||||||
self.cancel_timer(self.handle_wakueup_cb)
|
self.cancel_timer(self.handle_wakueup_cb)
|
||||||
self.handle_wakueup_cb = None
|
self.handle_wakueup_cb = None
|
||||||
|
|
||||||
@@ -311,7 +309,7 @@ class AlarmClock(SmartObject):
|
|||||||
if datetime.datetime.now().hour >= 4:
|
if datetime.datetime.now().hour >= 4:
|
||||||
program_next_wakeup_time = program_next_wakeup_time + datetime.timedelta(days=1)
|
program_next_wakeup_time = program_next_wakeup_time + datetime.timedelta(days=1)
|
||||||
|
|
||||||
self.log("program next wakeup cb registered for " + str(program_next_wakeup_time))
|
self.log_info("program next wakeup cb registered for " + str(program_next_wakeup_time))
|
||||||
self.handle_program_next_wakueup_cb = self.run_at(self.on_program_next_wakeup, program_next_wakeup_time)
|
self.handle_program_next_wakueup_cb = self.run_at(self.on_program_next_wakeup, program_next_wakeup_time)
|
||||||
self.sensor_wakeup_datetime_entity.set_state(state = 'unavailable')
|
self.sensor_wakeup_datetime_entity.set_state(state = 'unavailable')
|
||||||
else:
|
else:
|
||||||
@@ -326,17 +324,18 @@ class AlarmClock(SmartObject):
|
|||||||
prewakeup_datetime = wakeup_datetime - datetime.timedelta(minutes=prewakeup_offset)
|
prewakeup_datetime = wakeup_datetime - datetime.timedelta(minutes=prewakeup_offset)
|
||||||
|
|
||||||
self.handle_prewakueup_cb = self.run_at(self.on_pre_wakeup, prewakeup_datetime,wakeup_in = prewakeup_offset)
|
self.handle_prewakueup_cb = self.run_at(self.on_pre_wakeup, prewakeup_datetime,wakeup_in = prewakeup_offset)
|
||||||
self.log("prewakup cb registered for " + str(prewakeup_datetime))
|
self.log_info("prewakup cb registered for " + str(prewakeup_datetime))
|
||||||
|
|
||||||
self.handle_wakueup_cb = self.run_at(self.on_wakeup, wakeup_datetime)
|
self.handle_wakueup_cb = self.run_at(self.on_wakeup, wakeup_datetime)
|
||||||
self.log("wakeup cb registered for " + str(wakeup_datetime))
|
self.log_info("wakeup cb registered for " + str(wakeup_datetime))
|
||||||
|
|
||||||
self.handle_program_next_wakueup_cb = self.run_at(self.on_program_next_wakeup, program_next_wakeup_datetime)
|
self.handle_program_next_wakueup_cb = self.run_at(self.on_program_next_wakeup, program_next_wakeup_datetime)
|
||||||
self.log("program next wakeup cb registered for " + str(program_next_wakeup_datetime))
|
self.log_info("program next wakeup cb registered for " + str(program_next_wakeup_datetime))
|
||||||
|
|
||||||
self.wakeup_time_str = wakeup_time_str
|
self.wakeup_time_str = wakeup_time_str
|
||||||
self.call_service("input_text/set_value", entity_id = self.input_text_wakeup_time, value = self.wakeup_time_str)
|
#self.call_service("input_text/set_value", entity_id = self.input_text_wakeup_time, value = self.wakeup_time_str)
|
||||||
|
|
||||||
|
|
||||||
def update_wakeup_time(self):
|
def update_wakeup_time(self):
|
||||||
self.register_callbacks(self.calculate_wakeup_time())
|
with self.log_group("update_wakeup_time"):
|
||||||
|
self.register_callbacks(self.calculate_wakeup_time())
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
"""MQTT-discovered boiler climate; MQTT transport is Home Assistant's service.
|
||||||
|
|
||||||
|
Incoming MQTT is forwarded by the companion Home Assistant automation. All
|
||||||
|
callbacks run on the app's pinned AppDaemon thread; no broker credentials.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import time
|
||||||
|
|
||||||
|
import appdaemon.plugins.hass.hassapi as hass
|
||||||
|
from boilerregulator import BoilerRegulator, finite, setmode
|
||||||
|
from logger_interface import LoggerInterface
|
||||||
|
|
||||||
|
|
||||||
|
class BoilerClimate(hass.Hass, LoggerInterface):
|
||||||
|
def initialize(self):
|
||||||
|
self.initialize_logger_interface(self.get_ad_api(), ascii_encode=False)
|
||||||
|
self.base = self.args.get('mqtt_topic', 'appdaemon/radiator_heating').rstrip('/')
|
||||||
|
self.boiler = self.args.get('boiler_topic', 'themaplus-condens').rstrip('/')
|
||||||
|
self.uid = self.args.get('unique_id', 'appdaemon_radiator_heating')
|
||||||
|
self.title = self.args.get('display_name', 'Chauffage radiateurs')
|
||||||
|
self.indoor = self.args['indoor_sensor']
|
||||||
|
self.outdoor = self.args['outdoor_sensor']
|
||||||
|
self.interval = finite(self.args.get('command_interval', 60))
|
||||||
|
if not 10 <= self.interval <= 300:
|
||||||
|
raise ValueError('command_interval must be between 10 and 300 seconds')
|
||||||
|
self.min_target, self.max_target = 10.0, 23.0
|
||||||
|
self.target = finite(self.args.get('target_temperature', 18))
|
||||||
|
self.mode = self.args.get('initial_mode', 'off')
|
||||||
|
if self.mode not in ('off', 'heat') or not self.min_target <= self.target <= self.max_target:
|
||||||
|
raise ValueError('Invalid initial mode or room target')
|
||||||
|
self.send_commands = self.args.get('send_commands', False) is True
|
||||||
|
self.controller = BoilerRegulator(**self.args.get('regulation', {}))
|
||||||
|
self.state_file = Path(str(self.AD.app_dir)) / 'data' / (self.uid + '.json')
|
||||||
|
self._restore()
|
||||||
|
self.previous_time = time.monotonic()
|
||||||
|
self.last_send = 0.0
|
||||||
|
self.last_payload = None
|
||||||
|
self.last_command_at = None
|
||||||
|
self.last_echo_at = None
|
||||||
|
self.telemetry = {}
|
||||||
|
self.sensor_error = None
|
||||||
|
self.last_status_time = 0.0
|
||||||
|
self._state = None
|
||||||
|
self.listen_event(self._mqtt_event, 'APPDAEMON_BOILER_MQTT')
|
||||||
|
self.listen_state(self._sensor_changed, self.indoor, attribute='all')
|
||||||
|
self.listen_state(self._sensor_changed, self.outdoor, attribute='all')
|
||||||
|
self._discovery()
|
||||||
|
self.run_every(self._tick, 'now', self.interval)
|
||||||
|
self._control()
|
||||||
|
self.log_info(f'Boiler climate ready: MQTT writes={self.send_commands}, room target={self.target} °C')
|
||||||
|
|
||||||
|
def _publish(self, topic, payload, retain=False):
|
||||||
|
if not isinstance(payload, str):
|
||||||
|
payload = json.dumps(payload, allow_nan=False, ensure_ascii=False)
|
||||||
|
self.call_service('mqtt/publish', topic=topic, payload=payload, qos=0, retain=retain)
|
||||||
|
|
||||||
|
def _discovery(self):
|
||||||
|
device = dict(identifiers=[self.uid], name=self.title,
|
||||||
|
manufacturer='AppDaemon', model='Weather compensation + room PI')
|
||||||
|
common = dict(device=device, availability_topic=self.base + '/availability')
|
||||||
|
config = dict(common, name=None, unique_id=self.uid,
|
||||||
|
default_entity_id='climate.radiator_heating',
|
||||||
|
modes=['off', 'heat'], temperature_unit='C', precision=0.1,
|
||||||
|
temp_step=0.5, min_temp=self.min_target, max_temp=self.max_target,
|
||||||
|
mode_command_topic=self.base + '/command/mode',
|
||||||
|
temperature_command_topic=self.base + '/command/temperature',
|
||||||
|
mode_state_topic=self.base + '/state',
|
||||||
|
mode_state_template='{{ value_json.mode }}',
|
||||||
|
temperature_state_topic=self.base + '/state',
|
||||||
|
temperature_state_template='{{ value_json.target }}',
|
||||||
|
current_temperature_topic=self.base + '/state',
|
||||||
|
current_temperature_template='{{ value_json.indoor }}',
|
||||||
|
action_topic=self.base + '/state', action_template='{{ value_json.action }}',
|
||||||
|
json_attributes_topic=self.base + '/attributes', retain=False, qos=0)
|
||||||
|
self._publish('homeassistant/climate/' + self.uid + '/config', config, True)
|
||||||
|
for key, label in [('flow_requested', 'Température eau demandée'),
|
||||||
|
('curve', 'Loi d’eau'), ('proportional', 'Correction proportionnelle'),
|
||||||
|
('integral', 'Correction intégrale')]:
|
||||||
|
self._publish('homeassistant/sensor/' + self.uid + '/' + key + '/config',
|
||||||
|
dict(common, name=label, unique_id=self.uid + '_' + key,
|
||||||
|
state_topic=self.base + '/state',
|
||||||
|
value_template='{{ value_json.' + key + ' }}',
|
||||||
|
unit_of_measurement='°C', entity_category='diagnostic'), True)
|
||||||
|
self._publish('homeassistant/sensor/' + self.uid + '/reason/config',
|
||||||
|
dict(device=device, name='État de la régulation', unique_id=self.uid + '_reason',
|
||||||
|
state_topic=self.base + '/state', value_template='{{ value_json.reason }}',
|
||||||
|
json_attributes_topic=self.base + '/state',
|
||||||
|
json_attributes_template='{{ {"sensor_error": value_json.sensor_error} | tojson }}',
|
||||||
|
expire_after=180, entity_category='diagnostic'), True)
|
||||||
|
self._publish('homeassistant/sensor/' + self.uid + '/heartbeat/config',
|
||||||
|
dict(name='Heartbeat', device=device, unique_id=self.uid + '_heartbeat',
|
||||||
|
state_topic=self.base + '/heartbeat', expire_after=180,
|
||||||
|
entity_category='diagnostic', device_class='timestamp'), True)
|
||||||
|
|
||||||
|
def _restore(self):
|
||||||
|
try:
|
||||||
|
data = json.loads(self.state_file.read_text())
|
||||||
|
if not isinstance(data, dict) or data.get('mode') not in ('heat', 'off'):
|
||||||
|
raise ValueError('Invalid saved climate mode')
|
||||||
|
self.mode = data['mode']
|
||||||
|
target = finite(data['target'])
|
||||||
|
if not self.min_target <= target <= self.max_target:
|
||||||
|
raise ValueError('Saved room target out of bounds')
|
||||||
|
self.target = target
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass # First start uses the configured initial mode and target.
|
||||||
|
except (OSError, ValueError, KeyError, TypeError) as exc:
|
||||||
|
self.mode = 'off'
|
||||||
|
self.log_warning(f'Could not restore climate state; heating remains off: {exc}')
|
||||||
|
|
||||||
|
def _save(self):
|
||||||
|
self.state_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = self.state_file.with_suffix('.tmp')
|
||||||
|
tmp.write_text(json.dumps(dict(mode=self.mode, target=self.target)))
|
||||||
|
tmp.replace(self.state_file)
|
||||||
|
|
||||||
|
def _save_safely(self):
|
||||||
|
try:
|
||||||
|
self._save()
|
||||||
|
except OSError as exc:
|
||||||
|
self.log_error(f'Could not save climate state; boiler control continues: {exc}')
|
||||||
|
|
||||||
|
def _sensor_value(self, entity):
|
||||||
|
data = self.get_state(entity, attribute='all')
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ValueError(f'{entity}: sensor missing')
|
||||||
|
attributes = data.get('attributes', {})
|
||||||
|
name = attributes.get('friendly_name')
|
||||||
|
label = f'{entity} ({name})' if name else entity
|
||||||
|
if attributes.get('unit_of_measurement') != '°C':
|
||||||
|
raise ValueError(f'{label}: expected Celsius unit')
|
||||||
|
try:
|
||||||
|
value = finite(data.get('state'))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
raise ValueError(f"{label}: invalid state {data.get('state')!r}") from None
|
||||||
|
if not -40 <= value <= 60:
|
||||||
|
raise ValueError(f'{label}: implausible temperature {value} °C')
|
||||||
|
return value
|
||||||
|
|
||||||
|
def _sensor_changed(self, entity, attribute, old, new, kwargs):
|
||||||
|
self._control()
|
||||||
|
|
||||||
|
def _tick(self, kwargs=None):
|
||||||
|
self._control()
|
||||||
|
|
||||||
|
def _mqtt_event(self, event, data, kwargs):
|
||||||
|
topic, payload = data.get('topic'), data.get('payload', '')
|
||||||
|
if topic == 'homeassistant/status' and payload == 'online':
|
||||||
|
self._discovery()
|
||||||
|
self._control()
|
||||||
|
return
|
||||||
|
if topic == self.base + '/command/mode':
|
||||||
|
if payload not in ('off', 'heat'):
|
||||||
|
self.log_warning('Rejected invalid climate mode')
|
||||||
|
return
|
||||||
|
self.mode = payload
|
||||||
|
self.controller.reset()
|
||||||
|
self._control(force=True)
|
||||||
|
self._save_safely()
|
||||||
|
elif topic == self.base + '/command/temperature':
|
||||||
|
try:
|
||||||
|
target = finite(payload)
|
||||||
|
if not self.min_target <= target <= self.max_target:
|
||||||
|
raise ValueError('Target out of bounds')
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
self.log_warning('Rejected invalid room target')
|
||||||
|
return
|
||||||
|
self.target = target
|
||||||
|
self.controller.reset()
|
||||||
|
self._control(force=True)
|
||||||
|
self._save_safely()
|
||||||
|
elif topic and topic.startswith(self.boiler + '/bai/'):
|
||||||
|
try:
|
||||||
|
value = payload if isinstance(payload, dict) else json.loads(payload)
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return
|
||||||
|
name = topic.rsplit('/', 1)[-1]
|
||||||
|
self.telemetry[name] = value
|
||||||
|
if name == 'Statenumber':
|
||||||
|
self.last_status_time = time.monotonic()
|
||||||
|
if name == 'SetMode' and self.last_payload is not None:
|
||||||
|
if all(value.get(k) == v for k, v in self.last_payload.items()):
|
||||||
|
self.last_echo_at = datetime.now(timezone.utc).isoformat()
|
||||||
|
# Telemetry is diagnostic only: it never renews a boiler command.
|
||||||
|
if name == 'Statenumber':
|
||||||
|
self._publish_state()
|
||||||
|
self._attributes()
|
||||||
|
|
||||||
|
def _publish_state(self, now=None):
|
||||||
|
if getattr(self, '_state', None) is None:
|
||||||
|
return
|
||||||
|
now = time.monotonic() if now is None else now
|
||||||
|
status = self.telemetry.get('Statenumber', {}).get('value')
|
||||||
|
heating = status in (4, '4') and now - self.last_status_time < 180
|
||||||
|
self._state['action'] = 'off' if self.mode == 'off' else ('heating' if heating else 'idle')
|
||||||
|
self._publish(self.base + '/state', self._state.copy(), True)
|
||||||
|
|
||||||
|
def _attributes(self):
|
||||||
|
self._publish(self.base + '/attributes', dict(
|
||||||
|
indoor_sensor=self.indoor, outdoor_sensor=self.outdoor,
|
||||||
|
commands_enabled=self.send_commands,
|
||||||
|
sensor_error=getattr(self, 'sensor_error', None),
|
||||||
|
last_command_at=self.last_command_at, last_c6_echo_at=self.last_echo_at,
|
||||||
|
last_command=self.last_payload, boiler_telemetry=self.telemetry,
|
||||||
|
note='C6 echo confirms transmission, not effective boiler setpoint'), True)
|
||||||
|
|
||||||
|
def _control(self, force=False):
|
||||||
|
now = time.monotonic()
|
||||||
|
dt = min(max(0, now - self.previous_time), self.interval * 2)
|
||||||
|
self.previous_time = now
|
||||||
|
indoor = outdoor = None
|
||||||
|
result = None
|
||||||
|
readings, errors = {}, []
|
||||||
|
for entity in (self.indoor, self.outdoor):
|
||||||
|
try:
|
||||||
|
readings[entity] = self._sensor_value(entity)
|
||||||
|
except (ValueError, TypeError, KeyError) as exc:
|
||||||
|
errors.append(str(exc))
|
||||||
|
indoor, outdoor = readings.get(self.indoor), readings.get(self.outdoor)
|
||||||
|
valid = not errors
|
||||||
|
sensor_error = '; '.join(errors) if errors else None
|
||||||
|
# Log once per set of failing sensors, not every minute as age increases.
|
||||||
|
signature = tuple((entity, entity in readings) for entity in (self.indoor, self.outdoor))
|
||||||
|
previous = getattr(self, '_sensor_fault_signature', None)
|
||||||
|
if not valid and signature != previous:
|
||||||
|
self.log_error(f'Heating inhibited: sensor fault: {sensor_error}')
|
||||||
|
elif valid and previous is not None and previous != signature:
|
||||||
|
self.log_info('Temperature sensors recovered; regulation can resume. '
|
||||||
|
f"Previous fault: {getattr(self, 'sensor_error', None)}")
|
||||||
|
self._sensor_fault_signature = signature
|
||||||
|
self.sensor_error = sensor_error
|
||||||
|
if self.mode == 'off' or not valid:
|
||||||
|
self.controller.reset()
|
||||||
|
reason = 'off' if self.mode == 'off' else 'invalid_sensor'
|
||||||
|
enabled, flow = False, 0
|
||||||
|
else:
|
||||||
|
result = self.controller.update(self.target, indoor, outdoor, dt)
|
||||||
|
enabled, flow, reason = result.enabled, result.flow, result.reason
|
||||||
|
command = setmode(flow, enabled)
|
||||||
|
if self.send_commands and (force or command != self.last_payload or now - self.last_send >= self.interval):
|
||||||
|
self._publish(self.boiler + '/bai/SetMode/set', command, False)
|
||||||
|
if command != self.last_payload:
|
||||||
|
self.log_info(f"Heating request: enabled={enabled}, flow={command['flowtempdesired']} °C, reason={reason}")
|
||||||
|
self.last_payload, self.last_send = command, now
|
||||||
|
self.last_command_at = datetime.now(timezone.utc).isoformat()
|
||||||
|
self._state = dict(mode=self.mode, target=self.target, indoor=indoor,
|
||||||
|
outdoor=outdoor, reason=reason, sensor_error=self.sensor_error,
|
||||||
|
heating_requested=enabled, flow_requested=flow if enabled else 0,
|
||||||
|
curve=round(result.curve, 2) if result else 0,
|
||||||
|
proportional=round(result.proportional, 2) if result else 0,
|
||||||
|
integral=round(result.integral, 3) if result else 0)
|
||||||
|
self._publish_state(now)
|
||||||
|
self._attributes()
|
||||||
|
self._publish(self.base + '/availability', 'online' if valid else 'offline', True)
|
||||||
|
self._publish(self.base + '/heartbeat', datetime.now(timezone.utc).isoformat())
|
||||||
|
# Request actual status, without writing or renewing SetMode.
|
||||||
|
self._publish(self.boiler + '/bai/Statenumber/get', {})
|
||||||
|
self._publish(self.boiler + '/bai/FlowTemp/get', {})
|
||||||
|
|
||||||
|
def terminate(self):
|
||||||
|
# An orderly shutdown explicitly inhibits space heating; a crash relies
|
||||||
|
# on boiler command expiration. No retained actuator command, ever.
|
||||||
|
if not hasattr(self, 'base'):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
if self.send_commands:
|
||||||
|
self._publish(self.boiler + '/bai/SetMode/set', setmode(0, False))
|
||||||
|
except Exception as exc:
|
||||||
|
self.log_error(f'Could not publish shutdown; boiler expiration remains the fallback: {exc}')
|
||||||
|
try:
|
||||||
|
self._publish(self.base + '/availability', 'offline', True)
|
||||||
|
except Exception as exc:
|
||||||
|
self.log_warning(f'Could not publish offline availability: {exc}')
|
||||||
|
self._save_safely()
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""Weather compensation and room PI correction, expressed in water degrees."""
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import math
|
||||||
|
|
||||||
|
|
||||||
|
def finite(value):
|
||||||
|
value = float(value)
|
||||||
|
if not math.isfinite(value):
|
||||||
|
raise ValueError('Temperature must be finite')
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def clamp(value, low, high):
|
||||||
|
return max(low, min(high, value))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Regulation:
|
||||||
|
flow: float
|
||||||
|
enabled: bool
|
||||||
|
curve: float
|
||||||
|
proportional: float
|
||||||
|
integral: float
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
|
class BoilerRegulator:
|
||||||
|
def __init__(self, *, slope=1.5, offset=10, kp=5, ki=0.2,
|
||||||
|
integral_limit=8, correction_limit=12, min_flow=30,
|
||||||
|
max_flow=50, overheat=0.5, restart_margin=0.1):
|
||||||
|
self.slope, self.offset = finite(slope), finite(offset)
|
||||||
|
self.kp, self.ki = finite(kp), finite(ki)
|
||||||
|
self.integral_limit, self.correction_limit = finite(integral_limit), finite(correction_limit)
|
||||||
|
self.min_flow, self.max_flow = finite(min_flow), finite(max_flow)
|
||||||
|
self.overheat, self.restart_margin = finite(overheat), finite(restart_margin)
|
||||||
|
if not (0 <= self.min_flow < self.max_flow <= 80):
|
||||||
|
raise ValueError('Require 0 <= min_flow < max_flow <= 80')
|
||||||
|
if min(self.slope, self.kp, self.ki, self.integral_limit, self.correction_limit) < 0:
|
||||||
|
raise ValueError('Gains and limits must be non-negative')
|
||||||
|
if not 0 <= self.restart_margin < self.overheat:
|
||||||
|
raise ValueError('Require 0 <= restart_margin < overheat')
|
||||||
|
self.integral = 0.0
|
||||||
|
self.enabled = False
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
self.integral = 0.0
|
||||||
|
self.enabled = False
|
||||||
|
|
||||||
|
def update(self, target, indoor, outdoor, dt):
|
||||||
|
target, indoor, outdoor, dt = map(finite, (target, indoor, outdoor, dt))
|
||||||
|
error = target - indoor
|
||||||
|
curve = target + self.slope * (target - outdoor) + self.offset
|
||||||
|
proportional = self.kp * error
|
||||||
|
# ki is water °C / (room °C * hour). No accumulated downtime.
|
||||||
|
candidate = clamp(self.integral + self.ki * error * max(0, dt) / 3600,
|
||||||
|
-self.integral_limit, self.integral_limit)
|
||||||
|
raw = curve + clamp(proportional + candidate, -self.correction_limit, self.correction_limit)
|
||||||
|
# Anti-windup for BOTH the water bounds and the correction bound.
|
||||||
|
saturated_high = raw >= self.max_flow or proportional + candidate >= self.correction_limit
|
||||||
|
saturated_low = raw <= self.min_flow or proportional + candidate <= -self.correction_limit
|
||||||
|
if not ((saturated_high and error > 0) or (saturated_low and error < 0)):
|
||||||
|
self.integral = candidate
|
||||||
|
raw = curve + clamp(proportional + self.integral, -self.correction_limit, self.correction_limit)
|
||||||
|
if indoor >= target + self.overheat:
|
||||||
|
self.enabled = False
|
||||||
|
reason = 'room_overheat'
|
||||||
|
elif raw <= self.min_flow - 1:
|
||||||
|
self.enabled = False
|
||||||
|
reason = 'low_demand'
|
||||||
|
elif not self.enabled:
|
||||||
|
if indoor <= target + self.restart_margin and raw >= self.min_flow + 1:
|
||||||
|
self.enabled = True
|
||||||
|
reason = 'regulating' if self.enabled else 'waiting_for_demand'
|
||||||
|
else:
|
||||||
|
reason = 'regulating'
|
||||||
|
flow = round(clamp(raw, self.min_flow, self.max_flow) * 2) / 2
|
||||||
|
return Regulation(flow, self.enabled, curve, proportional, self.integral, reason)
|
||||||
|
|
||||||
|
|
||||||
|
def setmode(flow, enabled):
|
||||||
|
"""Full C6 SetMode payload. No new domestic hot-water setpoint."""
|
||||||
|
return dict(hcmode='auto', flowtempdesired=finite(flow) if enabled else 0,
|
||||||
|
hwctempdesired=None, hwcflowtempdesired=None,
|
||||||
|
disablehc=0 if enabled else 1, disablehwctapping=0,
|
||||||
|
disablehwcload=0, remotecontrolhcpump=0,
|
||||||
|
releasebackup=0, releasecooling=0)
|
||||||
@@ -217,3 +217,33 @@ guestroom_heatpump:
|
|||||||
reset_events:
|
reset_events:
|
||||||
- good_morning
|
- good_morning
|
||||||
- good_bye
|
- good_bye
|
||||||
|
|
||||||
|
radiator_heating:
|
||||||
|
module: boilerclimate
|
||||||
|
class: BoilerClimate
|
||||||
|
display_name: Chauffage radiateurs
|
||||||
|
unique_id: appdaemon_radiator_heating
|
||||||
|
indoor_sensor: sensor.corridor_temperature
|
||||||
|
outdoor_sensor: sensor.ext_front_temperature
|
||||||
|
target_temperature: 18
|
||||||
|
initial_mode: heat
|
||||||
|
|
||||||
|
# Radiator heating enabled with the room-thermostat bridge CLOSED.
|
||||||
|
# false keeps the climate/diagnostics but suppresses SetMode writes.
|
||||||
|
send_commands: true
|
||||||
|
command_interval: 60
|
||||||
|
mqtt_topic: appdaemon/radiator_heating
|
||||||
|
boiler_topic: themaplus-condens
|
||||||
|
|
||||||
|
# Starting values, to tune from observations with radiator circulation enabled.
|
||||||
|
regulation:
|
||||||
|
slope: 1.5
|
||||||
|
offset: 10
|
||||||
|
kp: 5
|
||||||
|
ki: 0.2 # water °C / (room °C * hour)
|
||||||
|
integral_limit: 8
|
||||||
|
correction_limit: 12
|
||||||
|
min_flow: 30
|
||||||
|
max_flow: 50
|
||||||
|
overheat: 0.5
|
||||||
|
restart_margin: 0.1
|
||||||
@@ -154,6 +154,9 @@ vertex_2:
|
|||||||
priority: 5
|
priority: 5
|
||||||
|
|
||||||
virtual_sensors:
|
virtual_sensors:
|
||||||
|
default_values:
|
||||||
|
binary_sensor.is_tv_on: false
|
||||||
|
binary_sensor.vertex2_has_input_signal: false
|
||||||
sensors:
|
sensors:
|
||||||
binary_sensor.projector_screen_down: cover.living_room_projection_screen.current_position < 90
|
binary_sensor.projector_screen_down: cover.living_room_projection_screen.current_position < 90
|
||||||
|
|
||||||
@@ -183,10 +186,14 @@ vertex_2:
|
|||||||
name: TV
|
name: TV
|
||||||
icon: mdi:television
|
icon: mdi:television
|
||||||
use_audio_out_audio_flags: input_boolean.use_bravia_theater_for_the_frame
|
use_audio_out_audio_flags: input_boolean.use_bravia_theater_for_the_frame
|
||||||
|
force_plus5:
|
||||||
|
trigger_conditions: binary_sensor.is_tv_on or binary_sensor.vertex2_has_input_signal
|
||||||
|
blocking_conditions: not switch.the_frame_power_plug
|
||||||
1:
|
1:
|
||||||
name: Projector
|
name: Projector
|
||||||
icon: mdi:projector
|
icon: mdi:projector
|
||||||
use_audio_out_audio_flags: True
|
use_audio_out_audio_flags: True
|
||||||
|
force_plus5: switch.living_room_projector_plug
|
||||||
|
|
||||||
# vertex2_power_plug:
|
# vertex2_power_plug:
|
||||||
# entity: switch.fibaro_vertex_plug_51
|
# entity: switch.fibaro_vertex_plug_51
|
||||||
@@ -219,6 +226,8 @@ vertex_2:
|
|||||||
output_selected_sensor: sensor.vertex2_output_selected
|
output_selected_sensor: sensor.vertex2_output_selected
|
||||||
input_selected_sensor: sensor.vertex2_input_selected
|
input_selected_sensor: sensor.vertex2_input_selected
|
||||||
unreachable_sensor: binary_sensor.vertex2_unreachable
|
unreachable_sensor: binary_sensor.vertex2_unreachable
|
||||||
|
command_processing_sensor: binary_sensor.vertex2_command_processing
|
||||||
|
command_processing_timeout: 20
|
||||||
|
|
||||||
use_bravia_theater_for_the_frame:
|
use_bravia_theater_for_the_frame:
|
||||||
module: smartswitch
|
module: smartswitch
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
sleep_sensors:
|
sleep_sensors:
|
||||||
module: virtualsensors
|
module: virtualsensors
|
||||||
class: VirtualSensorsApp
|
class: VirtualSensorsApp
|
||||||
priority: 5 # default priority app is 50, since most of them require sensors created by virtual sensors, it's important that virtual_sensors start first
|
priority: 1 # Start before apps such as alarm_clock that validate these sensors during initialization.
|
||||||
|
|
||||||
sensors:
|
sensors:
|
||||||
binary_sensor.is_bedroom_sleeping: input_boolean.bedroom_sleeping or input_boolean.nap_switch
|
binary_sensor.is_bedroom_sleeping: input_boolean.bedroom_sleeping or input_boolean.nap_switch
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ virtual_sensors:
|
|||||||
retain_time: 10
|
retain_time: 10
|
||||||
conditions:
|
conditions:
|
||||||
trigger_conditions: (sensor.vertex2_video_signal and sensor.vertex2_video_signal != 'no signal') or sensor.vertex2_audio_signal
|
trigger_conditions: (sensor.vertex2_video_signal and sensor.vertex2_video_signal != 'no signal') or sensor.vertex2_audio_signal
|
||||||
disable_conditions: binary_sensor.vertex2_unreachable
|
disable_conditions: binary_sensor.vertex2_unreachable or binary_sensor.vertex2_command_processing
|
||||||
sensor_attributes:
|
sensor_attributes:
|
||||||
icon: sensor.vertex2_input_selected_icon
|
icon: sensor.vertex2_input_selected_icon
|
||||||
binary_sensor.is_using_apple_tv: binary_sensor.is_apple_tv_on and binary_sensor.vertex2_has_input_signal and sensor.vertex2_input_selected == 'AppleTV'
|
binary_sensor.is_using_apple_tv: binary_sensor.is_apple_tv_on and binary_sensor.vertex2_has_input_signal and sensor.vertex2_input_selected == 'AppleTV'
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ alarm_clock:
|
|||||||
switch_alarmclock_secondary: input_boolean.secondary_alarmclock
|
switch_alarmclock_secondary: input_boolean.secondary_alarmclock
|
||||||
inputdate_alarmclock_main: input_datetime.main_alarmclock
|
inputdate_alarmclock_main: input_datetime.main_alarmclock
|
||||||
inputdate_alarmclock_secondary: input_datetime.secondary_alarmclock
|
inputdate_alarmclock_secondary: input_datetime.secondary_alarmclock
|
||||||
input_text_wakeup_time: input_text.wakeup_time
|
sensor_wakeup_datetime: sensor.wakeup_datetime
|
||||||
output_workday_sensor: binary_sensor.pierre_workday
|
output_workday_sensor: binary_sensor.pierre_workday
|
||||||
|
|
||||||
sleeping_condition: binary_sensor.is_bedroom_sleeping
|
sleeping_condition: binary_sensor.is_bedroom_sleeping
|
||||||
@@ -98,7 +98,7 @@ class SamsungTheFrame(SmartObject):
|
|||||||
key_press_delay=0)
|
key_press_delay=0)
|
||||||
except (OSError, tv_exceptions.ConnectionFailure, tv_exceptions.ResponseError,
|
except (OSError, tv_exceptions.ConnectionFailure, tv_exceptions.ResponseError,
|
||||||
websocket.WebSocketException) as error:
|
websocket.WebSocketException) as error:
|
||||||
self.log_info(f"Volume {direction} failed: {error}", level="WARNING")
|
self.log_warning(f"Volume {direction} failed: {error}")
|
||||||
# Reconnect on the next press, without replaying a failed command.
|
# Reconnect on the next press, without replaying a failed command.
|
||||||
self.close_volume_connection()
|
self.close_volume_connection()
|
||||||
|
|
||||||
|
|||||||
+66
-72
@@ -15,9 +15,16 @@ class Vertex2(SmartObject):
|
|||||||
self._stop_interrupt_listener()
|
self._stop_interrupt_listener()
|
||||||
self._interrupt_stop = threading.Event()
|
self._interrupt_stop = threading.Event()
|
||||||
self._status_requested = threading.Event()
|
self._status_requested = threading.Event()
|
||||||
self._tx0_recovery_attempted = False
|
self._command_processing_lock = threading.Lock()
|
||||||
self._tx0_recovery_restore_handle = None
|
self._command_processing_handle = None
|
||||||
|
self._command_processing_deadline = None
|
||||||
|
self.command_processing_timeout = max(0, float(self.args.get('command_processing_timeout', 30)))
|
||||||
|
self.command_processing_sensor = None
|
||||||
|
if self.args.get('command_processing_sensor'):
|
||||||
|
self.command_processing_sensor = self.create_entity(
|
||||||
|
self.args['command_processing_sensor'],
|
||||||
|
state='off',
|
||||||
|
)
|
||||||
self.video_tx_signal_sensor = self.create_entity(
|
self.video_tx_signal_sensor = self.create_entity(
|
||||||
self.args['video_tx_signal_sensor'],
|
self.args['video_tx_signal_sensor'],
|
||||||
state='unavailable',
|
state='unavailable',
|
||||||
@@ -111,17 +118,23 @@ class Vertex2(SmartObject):
|
|||||||
output_index = int(output_key)
|
output_index = int(output_key)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
continue
|
continue
|
||||||
for condition_key in ('use_audio_out_audio_flags',):
|
for condition_key in ('use_audio_out_audio_flags', 'force_plus5'):
|
||||||
condition_config = config.get(condition_key)
|
condition_config = config.get(condition_key)
|
||||||
if not condition_config:
|
if condition_config is None:
|
||||||
continue
|
continue
|
||||||
|
callback_options = (
|
||||||
|
{'on_change_cb': self.on_output_plus5_condition_change,
|
||||||
|
'trigger_callback_on_activation': True}
|
||||||
|
if condition_key == 'force_plus5'
|
||||||
|
else {'on_update_cb': self.on_output_config_conditions_update,
|
||||||
|
'trigger_callback_on_activation': False}
|
||||||
|
)
|
||||||
self.output_config_conditions.setdefault(output_index, {})[condition_key] = SmartCondition.Evaluator(
|
self.output_config_conditions.setdefault(output_index, {})[condition_key] = SmartCondition.Evaluator(
|
||||||
self,
|
self,
|
||||||
condition_config,
|
condition_config,
|
||||||
condition_name=f'output_config_tx{output_index}_{condition_key}',
|
condition_name=f'output_config_tx{output_index}_{condition_key}',
|
||||||
on_update_cb=self.on_output_config_conditions_update,
|
|
||||||
pass_condition_name_to_cb=str(output_index),
|
pass_condition_name_to_cb=str(output_index),
|
||||||
trigger_callback_on_activation=False,
|
**callback_options,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.output_selection_evaluators = []
|
self.output_selection_evaluators = []
|
||||||
@@ -175,21 +188,12 @@ class Vertex2(SmartObject):
|
|||||||
buffer = ''
|
buffer = ''
|
||||||
status = {}
|
status = {}
|
||||||
status_started = None
|
status_started = None
|
||||||
interrupt_generation = 0
|
|
||||||
last_interrupt_at = None
|
|
||||||
last_interrupt_interval = None
|
|
||||||
request_generation = 0
|
|
||||||
request_interval = None
|
|
||||||
previous_snapshot = None
|
|
||||||
previous_snapshot_generation = None
|
|
||||||
|
|
||||||
while not interrupt_stop.is_set():
|
while not interrupt_stop.is_set():
|
||||||
if status_started is None and self._status_requested.is_set():
|
if status_started is None and self._status_requested.is_set():
|
||||||
self._status_requested.clear()
|
self._status_requested.clear()
|
||||||
status = {}
|
status = {}
|
||||||
status_started = time.monotonic()
|
status_started = time.monotonic()
|
||||||
request_generation = interrupt_generation
|
|
||||||
request_interval = last_interrupt_interval
|
|
||||||
for command in commands:
|
for command in commands:
|
||||||
connection.sendall((command + '\r').encode('ascii'))
|
connection.sendall((command + '\r').encode('ascii'))
|
||||||
|
|
||||||
@@ -213,11 +217,8 @@ class Vertex2(SmartObject):
|
|||||||
line, buffer = buffer.split('\n', 1)
|
line, buffer = buffer.split('\n', 1)
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
if line == 'int':
|
if line == 'int':
|
||||||
|
self._refresh_command_processing()
|
||||||
self.log_info('Vertex2 IP INTERRUPT received')
|
self.log_info('Vertex2 IP INTERRUPT received')
|
||||||
now = time.monotonic()
|
|
||||||
last_interrupt_interval = None if last_interrupt_at is None else now - last_interrupt_at
|
|
||||||
last_interrupt_at = now
|
|
||||||
interrupt_generation += 1
|
|
||||||
self._status_requested.set()
|
self._status_requested.set()
|
||||||
elif status_started is not None:
|
elif status_started is not None:
|
||||||
if line.startswith('insel '):
|
if line.startswith('insel '):
|
||||||
@@ -236,23 +237,6 @@ class Vertex2(SmartObject):
|
|||||||
tx0=snapshot['tx0'], tx1=snapshot['tx1'],
|
tx0=snapshot['tx0'], tx1=snapshot['tx1'],
|
||||||
audout=snapshot['audout'], insel=snapshot['insel'])
|
audout=snapshot['audout'], insel=snapshot['insel'])
|
||||||
status_started = None
|
status_started = None
|
||||||
consecutive = (previous_snapshot_generation is not None
|
|
||||||
and request_generation == previous_snapshot_generation + 1)
|
|
||||||
unchanged = previous_snapshot == snapshot
|
|
||||||
rx_has_signal = any(
|
|
||||||
snapshot[field].strip().lower() not in ('', 'no signal', 'unavailable')
|
|
||||||
for field in ('rx0', 'rx1')
|
|
||||||
)
|
|
||||||
if (unchanged and consecutive and request_interval is not None
|
|
||||||
and 2 <= request_interval < 3 and not rx_has_signal):
|
|
||||||
if not self._tx0_recovery_attempted:
|
|
||||||
self.run_in(self.trigger_tx0_stuck_workaround, 0,
|
|
||||||
interrupt_interval=request_interval,
|
|
||||||
snapshot=snapshot)
|
|
||||||
elif not unchanged:
|
|
||||||
self._tx0_recovery_attempted = False
|
|
||||||
previous_snapshot = snapshot
|
|
||||||
previous_snapshot_generation = request_generation
|
|
||||||
|
|
||||||
if status_started is not None and time.monotonic() - status_started > 5:
|
if status_started is not None and time.monotonic() - status_started > 5:
|
||||||
raise OSError('Vertex2 TCP status response timed out')
|
raise OSError('Vertex2 TCP status response timed out')
|
||||||
@@ -271,30 +255,6 @@ class Vertex2(SmartObject):
|
|||||||
self.log_warning(f'Vertex2 IP INTERRUPT unavailable: {error}')
|
self.log_warning(f'Vertex2 IP INTERRUPT unavailable: {error}')
|
||||||
interrupt_stop.wait(5)
|
interrupt_stop.wait(5)
|
||||||
|
|
||||||
def trigger_tx0_stuck_workaround(self, kwargs):
|
|
||||||
if self._tx0_recovery_attempted:
|
|
||||||
return
|
|
||||||
if self.output_selected_sensor.get_state() != self.get_output_config(0)['name']:
|
|
||||||
return
|
|
||||||
if self.unreachable_sensor.get_state() != 'off':
|
|
||||||
return
|
|
||||||
|
|
||||||
self._tx0_recovery_attempted = True
|
|
||||||
snapshot_summary = ', '.join(f'{key}={value!r}' for key, value in kwargs['snapshot'].items())
|
|
||||||
self.log_warning(
|
|
||||||
f"Vertex2 TX0 +5V workaround triggered: identical complete TCP status after "
|
|
||||||
f"an IP INTERRUPT interval of {kwargs['interrupt_interval']:.2f}s "
|
|
||||||
f"({snapshot_summary}); cycling TX0 +5V FORCE"
|
|
||||||
)
|
|
||||||
self.force_tx0_plus5(False)
|
|
||||||
self._tx0_recovery_restore_handle = self.run_in(self.restore_tx0_plus5_after_recovery, 1)
|
|
||||||
|
|
||||||
def restore_tx0_plus5_after_recovery(self, kwargs):
|
|
||||||
self._tx0_recovery_restore_handle = None
|
|
||||||
if self.output_selected_sensor.get_state() == self.get_output_config(0)['name']:
|
|
||||||
self.log_info('Vertex2 TX0 +5V workaround: restoring TX0 +5V FORCE')
|
|
||||||
self.force_tx0_plus5(True)
|
|
||||||
|
|
||||||
def _stop_interrupt_listener(self):
|
def _stop_interrupt_listener(self):
|
||||||
interrupt_stop = getattr(self, '_interrupt_stop', None)
|
interrupt_stop = getattr(self, '_interrupt_stop', None)
|
||||||
if interrupt_stop is not None:
|
if interrupt_stop is not None:
|
||||||
@@ -306,10 +266,13 @@ class Vertex2(SmartObject):
|
|||||||
if interrupt_thread.is_alive():
|
if interrupt_thread.is_alive():
|
||||||
raise RuntimeError('Previous Vertex2 IP INTERRUPT listener did not stop')
|
raise RuntimeError('Previous Vertex2 IP INTERRUPT listener did not stop')
|
||||||
self._interrupt_thread = None
|
self._interrupt_thread = None
|
||||||
restore_handle = getattr(self, '_tx0_recovery_restore_handle', None)
|
processing_lock = getattr(self, '_command_processing_lock', None)
|
||||||
if restore_handle is not None:
|
if processing_lock is not None:
|
||||||
self.cancel_timer(restore_handle)
|
with processing_lock:
|
||||||
self._tx0_recovery_restore_handle = None
|
self._command_processing_deadline = None
|
||||||
|
if self._command_processing_handle is not None:
|
||||||
|
self.cancel_timer(self._command_processing_handle)
|
||||||
|
self._command_processing_handle = None
|
||||||
|
|
||||||
def terminate(self):
|
def terminate(self):
|
||||||
self._stop_interrupt_listener()
|
self._stop_interrupt_listener()
|
||||||
@@ -360,7 +323,7 @@ class Vertex2(SmartObject):
|
|||||||
break
|
break
|
||||||
|
|
||||||
if output_selected is None:
|
if output_selected is None:
|
||||||
self.log_warning("No output_selection condition succeeded; disabling TX0 and TX1 +5V FORCE")
|
self.log_warning("No output_selection condition succeeded; clearing output selection")
|
||||||
self.disable_output_selection()
|
self.disable_output_selection()
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -394,13 +357,9 @@ class Vertex2(SmartObject):
|
|||||||
self.log_info(f"Selecting TX{output_index} as the active output")
|
self.log_info(f"Selecting TX{output_index} as the active output")
|
||||||
self.apply_output_audio_settings(output_index)
|
self.apply_output_audio_settings(output_index)
|
||||||
if output_index == 0:
|
if output_index == 0:
|
||||||
self.force_tx1_plus5(False)
|
|
||||||
self.set_edid_video_flags_to_tx0()
|
self.set_edid_video_flags_to_tx0()
|
||||||
self.force_tx0_plus5(True)
|
|
||||||
else:
|
else:
|
||||||
self.force_tx0_plus5(False)
|
|
||||||
self.set_edid_video_flags_to_tx1()
|
self.set_edid_video_flags_to_tx1()
|
||||||
self.force_tx1_plus5(True)
|
|
||||||
|
|
||||||
def disable_output_selection(self):
|
def disable_output_selection(self):
|
||||||
default_output_config = self.get_output_config(None)
|
default_output_config = self.get_output_config(None)
|
||||||
@@ -409,8 +368,15 @@ class Vertex2(SmartObject):
|
|||||||
attributes={"icon": default_output_config["icon"]},
|
attributes={"icon": default_output_config["icon"]},
|
||||||
)
|
)
|
||||||
self._refresh_selected_output_signal()
|
self._refresh_selected_output_signal()
|
||||||
self.force_tx0_plus5(False)
|
|
||||||
self.force_tx1_plus5(False)
|
def on_output_plus5_condition_change(self, output_index, previous_result, result):
|
||||||
|
if result == SmartCondition.Result.Disabled:
|
||||||
|
return
|
||||||
|
enabled = result == SmartCondition.Result.Succeeded
|
||||||
|
if int(output_index) == 0:
|
||||||
|
self.force_tx0_plus5(enabled)
|
||||||
|
elif int(output_index) == 1:
|
||||||
|
self.force_tx1_plus5(enabled)
|
||||||
|
|
||||||
def on_filmmaker_mode_conditions_change(self,prev_result,result):
|
def on_filmmaker_mode_conditions_change(self,prev_result,result):
|
||||||
if result == SmartCondition.Result.Succeeded:
|
if result == SmartCondition.Result.Succeeded:
|
||||||
@@ -424,8 +390,35 @@ class Vertex2(SmartObject):
|
|||||||
else:
|
else:
|
||||||
self.turn_off(self.power_plug_entity)
|
self.turn_off(self.power_plug_entity)
|
||||||
|
|
||||||
|
def _refresh_command_processing(self, start=False):
|
||||||
|
if self.command_processing_sensor is None:
|
||||||
|
return
|
||||||
|
with self._command_processing_lock:
|
||||||
|
if not start and self.command_processing_sensor.get_state() != 'on':
|
||||||
|
return
|
||||||
|
if self._command_processing_handle is not None:
|
||||||
|
self.cancel_timer(self._command_processing_handle)
|
||||||
|
self._command_processing_deadline = time.monotonic() + self.command_processing_timeout
|
||||||
|
if self.command_processing_sensor.get_state() != 'on':
|
||||||
|
self.command_processing_sensor.set_state(state='on')
|
||||||
|
self._command_processing_handle = self.run_in(
|
||||||
|
self._on_command_processing_timeout,
|
||||||
|
self.command_processing_timeout,
|
||||||
|
deadline=self._command_processing_deadline,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_command_processing_timeout(self, kwargs):
|
||||||
|
with self._command_processing_lock:
|
||||||
|
# Ignore a callback already queued before a timer reset or shutdown.
|
||||||
|
if kwargs['deadline'] != self._command_processing_deadline:
|
||||||
|
return
|
||||||
|
self._command_processing_handle = None
|
||||||
|
self._command_processing_deadline = None
|
||||||
|
self.command_processing_sensor.set_state(state='off')
|
||||||
|
|
||||||
def send_cmd(self,cmd,value = ''):
|
def send_cmd(self,cmd,value = ''):
|
||||||
request = f'{self.args["vertex2_address"]}/cmd?{cmd}={value}'
|
request = f'{self.args["vertex2_address"]}/cmd?{cmd}={value}'
|
||||||
|
self._refresh_command_processing(start=True)
|
||||||
try:
|
try:
|
||||||
r = get(request, timeout=5)
|
r = get(request, timeout=5)
|
||||||
if cmd == 'edid' and value.startswith(('audiotx', 'audioout')):
|
if cmd == 'edid' and value.startswith(('audiotx', 'audioout')):
|
||||||
@@ -537,6 +530,7 @@ class Vertex2(SmartObject):
|
|||||||
"name": config.get("name", default_config["name"]),
|
"name": config.get("name", default_config["name"]),
|
||||||
"icon": config.get("icon", default_config["icon"]),
|
"icon": config.get("icon", default_config["icon"]),
|
||||||
"use_audio_out_audio_flags": config.get("use_audio_out_audio_flags"),
|
"use_audio_out_audio_flags": config.get("use_audio_out_audio_flags"),
|
||||||
|
"force_plus5": config.get("force_plus5"),
|
||||||
}
|
}
|
||||||
return {"name": config, "icon": None}
|
return {"name": config, "icon": None}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# Chauffage radiateurs — AppDaemon et C6
|
||||||
|
|
||||||
|
L'app `radiator_heating` expose `climate.radiator_heating` par MQTT Discovery.
|
||||||
|
Les publications utilisent le service `mqtt.publish` de Home Assistant, comme
|
||||||
|
l'ancienne implémentation de SmartObject. Aucun identifiant MQTT supplémentaire
|
||||||
|
ni plugin MQTT AppDaemon n'est nécessaire.
|
||||||
|
|
||||||
|
## Fichiers
|
||||||
|
|
||||||
|
- `apps/boilerclimate.py` : MQTT Discovery, consigne, mode, mesures et commandes.
|
||||||
|
- `apps/boilerregulator.py` : calcul indépendant de la température d'eau.
|
||||||
|
- `apps/global/heating.yaml`, bloc `radiator_heating` : paramètres actifs de l'app.
|
||||||
|
- `homeassistant/packages/radiator_heating_mqtt.yaml` : copie des deux
|
||||||
|
automatisations installées via l'API de configuration de Home Assistant.
|
||||||
|
Ne pas inclure aussi cette copie comme package : les automatisations existent
|
||||||
|
déjà dans Home Assistant. Leur modification dans cette copie ne les redéploie pas.
|
||||||
|
|
||||||
|
## Régulation
|
||||||
|
|
||||||
|
Avec S la consigne ambiante, Ti la température du couloir et Te celle de devant :
|
||||||
|
|
||||||
|
courbe = S + pente × (S − Te) + décalage
|
||||||
|
correction P = kp × (S − Ti)
|
||||||
|
correction I += ki × (S − Ti) × durée_en_heures
|
||||||
|
demande = courbe + correction bornée(P + I)
|
||||||
|
|
||||||
|
La sortie est arrondie au demi-degré et limitée entre 30 et 50 °C initialement.
|
||||||
|
`ki` est exprimé en degrés d'eau par degré d'écart intérieur et par heure.
|
||||||
|
Les bornes de sortie et de correction disposent d'une protection anti-windup.
|
||||||
|
L'intégrale est remise à zéro à l'arrêt, en cas de mesure invalide, au changement
|
||||||
|
manuel de consigne et au redémarrage. Consigne et mode sont persistés dans
|
||||||
|
`apps/data/appdaemon_radiator_heating.json`, exclu de Git.
|
||||||
|
Une sauvegarde absente au premier lancement utilise la configuration initiale.
|
||||||
|
Une sauvegarde illisible ou invalide impose le mode `off` avec un avertissement.
|
||||||
|
Une erreur d'écriture est journalisée sans bloquer la commande chaudière,
|
||||||
|
notamment l'arrêt demandé par l'utilisateur ou l'arrêt ordonné de l'app.
|
||||||
|
|
||||||
|
À l'équilibre, la chauffe peut rester demandée même lorsque Ti = S.
|
||||||
|
L'app inhibe le chauffage si Ti >= S + 0,5 °C, ou si la demande calculée descend
|
||||||
|
sous la température minimale utile (avec une hystérésis de 1 °C d'eau).
|
||||||
|
Après arrêt, le redémarrage exige Ti <= S + 0,1 °C et une demande suffisante.
|
||||||
|
Les gains sont des valeurs initiales à ajuster après les essais de circulation,
|
||||||
|
pas des coefficients identifiés pour cette maison.
|
||||||
|
|
||||||
|
Les deux capteurs doivent fournir des valeurs finies en °C, entre −40 et 60 °C.
|
||||||
|
Une température inchangée, même ancienne, reste valide : ces sondes peuvent
|
||||||
|
ne publier qu'en cas de changement. Aucun délai sur l'âge de la mesure n'est
|
||||||
|
appliqué. Les états indisponibles/inconnus, les valeurs invalides et les unités
|
||||||
|
incorrectes provoquent toujours l'inhibition. Une sonde déconnectée conservant
|
||||||
|
une valeur plausible n'est donc pas détectée par ce contrôle.
|
||||||
|
|
||||||
|
Un défaut publie un log ERROR avec l'identifiant, le nom du capteur et sa cause
|
||||||
|
(état invalide, unité ou valeur incohérente). Le log est émis
|
||||||
|
à l'apparition du défaut ou au changement des capteurs concernés, sans répétition
|
||||||
|
chaque minute. Le détail actualisé reste disponible dans l'attribut `sensor_error`
|
||||||
|
du capteur « État de la régulation », même quand le climate est indisponible.
|
||||||
|
Un log INFO signale le rétablissement. L'app utilise le pipeline standard
|
||||||
|
`LoggerInterface` (`log_info`, `log_warning`, `log_error`) ; les erreurs sont
|
||||||
|
également envoyées dans `error_log`. L'option `ascii_encode=False`, propre à
|
||||||
|
cette app, conserve les accents et le symbole °C.
|
||||||
|
|
||||||
|
## MQTT et disponibilité
|
||||||
|
|
||||||
|
- Configuration : `homeassistant/climate/appdaemon_radiator_heating/config`.
|
||||||
|
- Commandes utilisateur : `appdaemon/radiator_heating/command/mode` (`heat`/`off`)
|
||||||
|
et `/command/temperature` (consigne ambiante entre 10 et 23 °C).
|
||||||
|
- État et diagnostics : `appdaemon/radiator_heating/state` et `/attributes`.
|
||||||
|
- Chaudière : `themaplus-condens/bai/SetMode/set`.
|
||||||
|
|
||||||
|
L'automatisation de retour transmet les commandes MQTT et les retours chaudière
|
||||||
|
à l'événement `APPDAEMON_BOILER_MQTT`, écouté par l'app. Le watchdog marque le
|
||||||
|
climate indisponible après trois minutes sans heartbeat. Il ne commande PAS
|
||||||
|
la chaudière : il signale l'arrêt de l'app. Configuration et états sont retenus ;
|
||||||
|
les commandes utilisateur et chaudière ne doivent jamais être retenues.
|
||||||
|
|
||||||
|
`SetMode` est renouvelé chaque minute et lors d'un changement de demande.
|
||||||
|
Les retours de télémétrie ne renouvellent pas la commande. Un arrêt ordonné
|
||||||
|
publie une inhibition du chauffage ; un crash repose sur l'expiration observée
|
||||||
|
de la commande chaudière. Les champs de consigne ECS sont `null` et les bits
|
||||||
|
d'inhibition sanitaire restent à zéro.
|
||||||
|
|
||||||
|
`heating_requested` désigne la demande de l'app. L'action `heating` du climate
|
||||||
|
n'est annoncée qu'avec un état chaudière S.04 récent et est actualisée dès
|
||||||
|
réception du statut, sans recalcul ni renouvellement de commande ; une chauffe sanitaire
|
||||||
|
n'est pas comptée comme chauffage des radiateurs. Sans télémétrie récente,
|
||||||
|
`idle` ne constitue pas une preuve d'absence de flamme : consulter aussi la
|
||||||
|
chaudière. L'écho SetMode confirme une transmission C6, pas la consigne effective.
|
||||||
|
|
||||||
|
## Essais actuels
|
||||||
|
|
||||||
|
Les écritures sont autorisées (`send_commands: true`), pont thermostat fermé.
|
||||||
|
Le climate démarre en mode `heat`, consigne 18 °C, sauf état utilisateur persisté.
|
||||||
|
Un couloir plus chaud que la consigne entraîne normalement une inhibition.
|
||||||
|
Le retour au réglage local de 10 °C après environ 15 minutes sans renouvellement
|
||||||
|
a été observé pont ouvert. L'arrêt réel après perte de communication reste à
|
||||||
|
vérifier avec circulation autorisée ; ce délai n'est pas une coupure immédiate.
|
||||||
|
|
||||||
|
Un premier essai pont fermé avec une consigne ambiante de 23 °C a produit une
|
||||||
|
demande d'eau à 46 °C et un démarrage de la chauffe. Les réglages restent à
|
||||||
|
ajuster selon l'évolution réelle des températures. Ne pas modifier les mesures
|
||||||
|
réelles pour simuler la chauffe.
|
||||||
|
`send_commands: false` permet de conserver l'interface et les calculs sans écrire
|
||||||
|
SetMode. Les lectures de télémétrie continuent.
|
||||||
|
|
||||||
|
## Vérification
|
||||||
|
|
||||||
|
python -m unittest discover -s tests -p test_boilerclimate.py -v
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Installed through HA's automation configuration API. This is the reviewable
|
||||||
|
# source copy; do not also include it as a package if the automations exist.
|
||||||
|
automation:
|
||||||
|
- id: appdaemon_radiator_heating_bridge
|
||||||
|
alias: AppDaemon - Chauffage radiateurs - Retour MQTT
|
||||||
|
description: Transmet les commandes du climate et les retours C6 à AppDaemon.
|
||||||
|
triggers:
|
||||||
|
- trigger: mqtt
|
||||||
|
topic: appdaemon/radiator_heating/command/+
|
||||||
|
- trigger: mqtt
|
||||||
|
topic: themaplus-condens/bai/SetMode
|
||||||
|
- trigger: mqtt
|
||||||
|
topic: themaplus-condens/bai/Statenumber
|
||||||
|
- trigger: mqtt
|
||||||
|
topic: themaplus-condens/bai/FlowTemp
|
||||||
|
- trigger: mqtt
|
||||||
|
topic: homeassistant/status
|
||||||
|
actions:
|
||||||
|
- event: APPDAEMON_BOILER_MQTT
|
||||||
|
event_data:
|
||||||
|
topic: '{{ trigger.topic }}'
|
||||||
|
payload: '{{ trigger.payload }}'
|
||||||
|
mode: queued
|
||||||
|
max: 50
|
||||||
|
- id: appdaemon_radiator_heating_watchdog
|
||||||
|
alias: AppDaemon - Chauffage radiateurs - Disponibilité
|
||||||
|
description: Marque le climate indisponible après trois minutes sans heartbeat.
|
||||||
|
triggers:
|
||||||
|
- trigger: mqtt
|
||||||
|
topic: appdaemon/radiator_heating/heartbeat
|
||||||
|
id: heartbeat
|
||||||
|
- trigger: homeassistant
|
||||||
|
event: start
|
||||||
|
id: startup
|
||||||
|
actions:
|
||||||
|
- if:
|
||||||
|
- condition: template
|
||||||
|
value_template: "{{ trigger.id == 'heartbeat' }}"
|
||||||
|
then:
|
||||||
|
- delay:
|
||||||
|
minutes: 3
|
||||||
|
- action: mqtt.publish
|
||||||
|
data:
|
||||||
|
topic: appdaemon/radiator_heating/availability
|
||||||
|
payload: offline
|
||||||
|
retain: true
|
||||||
|
mode: restart
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
import json
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'apps'))
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'apps' / 'ad_toolbox'))
|
||||||
|
from boilerregulator import BoilerRegulator, setmode
|
||||||
|
from boilerclimate import BoilerClimate
|
||||||
|
|
||||||
|
|
||||||
|
class RegulatorTests(unittest.TestCase):
|
||||||
|
def test_equilibrium_retains_heat_at_target(self):
|
||||||
|
r = BoilerRegulator()
|
||||||
|
v = r.update(18, 18, 5, 60)
|
||||||
|
self.assertTrue(v.enabled)
|
||||||
|
self.assertEqual(v.flow, 47.5)
|
||||||
|
|
||||||
|
def test_colder_outdoors_and_room_increase_water_request(self):
|
||||||
|
mild = BoilerRegulator().update(18, 18, 10, 0)
|
||||||
|
cold = BoilerRegulator().update(18, 18, 5, 0)
|
||||||
|
cold_room = BoilerRegulator().update(18, 17, 10, 0)
|
||||||
|
self.assertGreater(cold.flow, mild.flow)
|
||||||
|
self.assertGreater(cold_room.flow, mild.flow)
|
||||||
|
|
||||||
|
def test_persistent_error_integrates_in_water_degrees(self):
|
||||||
|
r = BoilerRegulator(max_flow=65)
|
||||||
|
v = r.update(18, 17.5, 10, 3600)
|
||||||
|
self.assertAlmostEqual(v.integral, 0.1)
|
||||||
|
|
||||||
|
def test_upper_saturation_does_not_wind_up(self):
|
||||||
|
r = BoilerRegulator()
|
||||||
|
for _ in range(1000):
|
||||||
|
v = r.update(18, 12, -10, 60)
|
||||||
|
self.assertEqual(v.flow, 50)
|
||||||
|
self.assertEqual(v.integral, 0)
|
||||||
|
|
||||||
|
def test_overheat_and_low_demand_stop_with_hysteresis(self):
|
||||||
|
r = BoilerRegulator()
|
||||||
|
self.assertTrue(r.update(18, 18, 5, 0).enabled)
|
||||||
|
self.assertFalse(r.update(18, 18.6, 5, 60).enabled)
|
||||||
|
self.assertFalse(r.update(18, 18.2, 5, 60).enabled)
|
||||||
|
self.assertTrue(r.update(18, 18, 5, 60).enabled)
|
||||||
|
self.assertFalse(r.update(18, 18, 20, 60).enabled)
|
||||||
|
|
||||||
|
def test_payload_preserves_sanitary_fields(self):
|
||||||
|
a, b = setmode(40, True), setmode(0, False)
|
||||||
|
self.assertEqual(a['disablehc'], 0)
|
||||||
|
self.assertEqual(b['disablehc'], 1)
|
||||||
|
for p in [a, b]:
|
||||||
|
self.assertIsNone(p['hwctempdesired'])
|
||||||
|
self.assertIsNone(p['hwcflowtempdesired'])
|
||||||
|
self.assertEqual(p['disablehwctapping'], 0)
|
||||||
|
self.assertEqual(p['disablehwcload'], 0)
|
||||||
|
|
||||||
|
def test_rejects_nonfinite_inputs(self):
|
||||||
|
for value in [float('nan'), float('inf')]:
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
BoilerRegulator().update(18, value, 5, 60)
|
||||||
|
|
||||||
|
|
||||||
|
class ClimateTests(unittest.TestCase):
|
||||||
|
def app(self):
|
||||||
|
# Test the actual app callbacks without starting an AppDaemon server.
|
||||||
|
app = object.__new__(BoilerClimate)
|
||||||
|
app.base, app.boiler = 'appdaemon/test', 'boiler'
|
||||||
|
app.mode, app.target = 'heat', 18
|
||||||
|
app.indoor, app.outdoor = 'sensor.indoor', 'sensor.outdoor'
|
||||||
|
app.min_target, app.max_target = 10, 23
|
||||||
|
app.interval = 60
|
||||||
|
app.controller = BoilerRegulator()
|
||||||
|
app.send_commands = True
|
||||||
|
app.last_payload = None
|
||||||
|
app.last_send = app.previous_time = 1000
|
||||||
|
app.last_command_at = app.last_echo_at = None
|
||||||
|
app.telemetry, app.last_status_time = {}, 0
|
||||||
|
app._publish = Mock()
|
||||||
|
app._save = Mock()
|
||||||
|
app.log = Mock()
|
||||||
|
app.initialize_logger_interface(app, ascii_encode=False)
|
||||||
|
readings = {app.indoor: 18, app.outdoor: 10}
|
||||||
|
app._sensor_value = lambda entity: readings[entity]
|
||||||
|
return app
|
||||||
|
|
||||||
|
def writes(self, app):
|
||||||
|
return [c for c in app._publish.call_args_list if c.args[0] == 'boiler/bai/SetMode/set']
|
||||||
|
|
||||||
|
def test_single_change_and_periodic_refresh_only(self):
|
||||||
|
a = self.app()
|
||||||
|
with patch('boilerclimate.time.monotonic', return_value=1000):
|
||||||
|
a._control()
|
||||||
|
self.assertEqual(len(self.writes(a)), 1)
|
||||||
|
with patch('boilerclimate.time.monotonic', return_value=1030):
|
||||||
|
a._control()
|
||||||
|
self.assertEqual(len(self.writes(a)), 1)
|
||||||
|
with patch('boilerclimate.time.monotonic', return_value=1060):
|
||||||
|
a._control()
|
||||||
|
self.assertEqual(len(self.writes(a)), 2)
|
||||||
|
for call in self.writes(a):
|
||||||
|
self.assertFalse(call.args[2])
|
||||||
|
|
||||||
|
def test_invalid_sensor_explicitly_inhibits_heat(self):
|
||||||
|
a = self.app()
|
||||||
|
a._sensor_value = Mock(side_effect=ValueError('unavailable'))
|
||||||
|
a._control()
|
||||||
|
self.assertEqual(self.writes(a)[0].args[1]['disablehc'], 1)
|
||||||
|
self.assertEqual(a.controller.integral, 0)
|
||||||
|
|
||||||
|
def test_observation_mode_never_writes_boiler(self):
|
||||||
|
a = self.app()
|
||||||
|
a.send_commands = False
|
||||||
|
a._control()
|
||||||
|
self.assertEqual(self.writes(a), [])
|
||||||
|
|
||||||
|
def test_room_target_rejects_nan_and_out_of_bounds(self):
|
||||||
|
a = self.app()
|
||||||
|
for p in ['nan', 'inf', '30', 'garbage']:
|
||||||
|
a._mqtt_event('', dict(topic=a.base + '/command/temperature', payload=p), {})
|
||||||
|
self.assertEqual(a.target, 18)
|
||||||
|
self.assertFalse(a._save.called)
|
||||||
|
self.assertEqual(self.writes(a), [])
|
||||||
|
|
||||||
|
def test_off_command_sends_stop(self):
|
||||||
|
a = self.app()
|
||||||
|
a._mqtt_event('', dict(topic=a.base + '/command/mode', payload='off'), {})
|
||||||
|
self.assertEqual(self.writes(a)[0].args[1]['disablehc'], 1)
|
||||||
|
|
||||||
|
def test_native_ha_template_payload_is_accepted(self):
|
||||||
|
a = self.app()
|
||||||
|
a._mqtt_event('', dict(topic='boiler/bai/Statenumber', payload={'value': 30}), {})
|
||||||
|
self.assertEqual(a.telemetry['Statenumber'], {'value': 30})
|
||||||
|
self.assertEqual(self.writes(a), [])
|
||||||
|
|
||||||
|
def reading_app(self, state='18', age=0):
|
||||||
|
a = self.app()
|
||||||
|
stamp = (datetime.now(timezone.utc) - timedelta(seconds=age)).isoformat()
|
||||||
|
a.get_state = Mock(return_value={
|
||||||
|
'state': state,
|
||||||
|
'attributes': {'unit_of_measurement': '°C', 'friendly_name': 'Couloir Température'},
|
||||||
|
'last_updated': stamp,
|
||||||
|
})
|
||||||
|
return a
|
||||||
|
|
||||||
|
def test_unchanged_temperature_is_valid_even_after_days(self):
|
||||||
|
a = self.reading_app(age=86400 * 3)
|
||||||
|
self.assertEqual(BoilerClimate._sensor_value(a, a.indoor), 18)
|
||||||
|
|
||||||
|
def test_unavailable_is_invalid_without_waiting_two_hours(self):
|
||||||
|
a = self.reading_app(state='unavailable', age=1)
|
||||||
|
with self.assertRaisesRegex(ValueError, r'sensor.indoor.*invalid state.*unavailable'):
|
||||||
|
BoilerClimate._sensor_value(a, a.indoor)
|
||||||
|
|
||||||
|
def test_missing_timestamp_does_not_invalidate_temperature(self):
|
||||||
|
a = self.reading_app()
|
||||||
|
a.get_state.return_value.pop('last_updated')
|
||||||
|
self.assertEqual(BoilerClimate._sensor_value(a, a.indoor), 18)
|
||||||
|
|
||||||
|
def test_fault_logs_error_once_then_logs_recovery(self):
|
||||||
|
a = self.app()
|
||||||
|
a._sensor_value = Mock(side_effect=ValueError('sensor.indoor (Couloir): unavailable'))
|
||||||
|
a._control()
|
||||||
|
a._control()
|
||||||
|
errors = [c for c in a.log.call_args_list
|
||||||
|
if c.kwargs.get('level') == 'ERROR' and c.kwargs.get('log') == 'main_log']
|
||||||
|
self.assertEqual(len(errors), 1)
|
||||||
|
self.assertIn('Couloir', errors[0].args[0])
|
||||||
|
self.assertFalse(errors[0].kwargs['ascii_encode'])
|
||||||
|
self.assertTrue(any(c.kwargs.get('log') == 'error_log' for c in a.log.call_args_list))
|
||||||
|
self.assertIn('sensor.indoor', a.sensor_error)
|
||||||
|
a._sensor_value = lambda entity: 18 if entity == a.indoor else 10
|
||||||
|
a._control()
|
||||||
|
self.assertIsNone(a.sensor_error)
|
||||||
|
self.assertTrue(any('recovered' in c.args[0] for c in a.log.call_args_list))
|
||||||
|
|
||||||
|
def test_telemetry_does_not_refresh_command(self):
|
||||||
|
a = self.app()
|
||||||
|
a._mqtt_event('', dict(topic='boiler/bai/SetMode', payload=json.dumps(setmode(40, True))), {})
|
||||||
|
self.assertEqual(self.writes(a), [])
|
||||||
|
|
||||||
|
def test_save_failure_does_not_block_off(self):
|
||||||
|
a = self.app()
|
||||||
|
a._save.side_effect = OSError('disk full')
|
||||||
|
a._mqtt_event('', dict(topic=a.base + '/command/mode', payload='off'), {})
|
||||||
|
self.assertEqual(self.writes(a)[0].args[1]['disablehc'], 1)
|
||||||
|
self.assertTrue(any('disk full' in c.args[0] for c in a.log.call_args_list))
|
||||||
|
|
||||||
|
def test_save_failure_does_not_block_shutdown(self):
|
||||||
|
a = self.app()
|
||||||
|
a._save.side_effect = OSError('disk full')
|
||||||
|
a.terminate()
|
||||||
|
self.assertEqual(self.writes(a)[0].args[1]['disablehc'], 1)
|
||||||
|
a._publish.assert_any_call(a.base + '/availability', 'offline', True)
|
||||||
|
|
||||||
|
def test_shutdown_publish_failure_still_saves_and_marks_offline(self):
|
||||||
|
a = self.app()
|
||||||
|
def publish(topic, *args):
|
||||||
|
if topic == 'boiler/bai/SetMode/set':
|
||||||
|
raise RuntimeError('service unavailable')
|
||||||
|
a._publish.side_effect = publish
|
||||||
|
a.terminate()
|
||||||
|
a._save.assert_called_once()
|
||||||
|
a._publish.assert_any_call(a.base + '/availability', 'offline', True)
|
||||||
|
|
||||||
|
def test_invalid_persisted_state_starts_off(self):
|
||||||
|
with tempfile.TemporaryDirectory() as folder:
|
||||||
|
for saved in [{'mode': 'off', 'target': 'invalid'},
|
||||||
|
{'mode': 'heat', 'target': 30},
|
||||||
|
{'target': 18}, [], None]:
|
||||||
|
with self.subTest(saved=saved):
|
||||||
|
a = self.app()
|
||||||
|
a.state_file = Path(folder) / 'state.json'
|
||||||
|
a.state_file.write_text(json.dumps(saved))
|
||||||
|
a._restore()
|
||||||
|
self.assertEqual(a.mode, 'off')
|
||||||
|
self.assertEqual(a.target, 18)
|
||||||
|
self.assertTrue(any(c.kwargs.get('level') == 'WARNING'
|
||||||
|
for c in a.log.call_args_list))
|
||||||
|
|
||||||
|
def test_restore_valid_state_and_first_start(self):
|
||||||
|
with tempfile.TemporaryDirectory() as folder:
|
||||||
|
a = self.app()
|
||||||
|
a.state_file = Path(folder) / 'state.json'
|
||||||
|
a._restore()
|
||||||
|
self.assertEqual(a.mode, 'heat')
|
||||||
|
a.state_file.write_text(json.dumps({'mode': 'off', 'target': 19.5}))
|
||||||
|
a._restore()
|
||||||
|
self.assertEqual((a.mode, a.target), ('off', 19.5))
|
||||||
|
|
||||||
|
def test_corrupt_or_unreadable_state_starts_off(self):
|
||||||
|
for contents in ['{broken', OSError('permission denied')]:
|
||||||
|
with self.subTest(contents=contents):
|
||||||
|
a = self.app()
|
||||||
|
a.state_file = Mock()
|
||||||
|
if isinstance(contents, OSError):
|
||||||
|
a.state_file.read_text.side_effect = contents
|
||||||
|
else:
|
||||||
|
a.state_file.read_text.return_value = contents
|
||||||
|
a._restore()
|
||||||
|
self.assertEqual(a.mode, 'off')
|
||||||
|
|
||||||
|
def test_standard_log_pipeline_preserves_unicode_and_error_routing(self):
|
||||||
|
a = self.app()
|
||||||
|
a.log_info('Demande à 40 °C')
|
||||||
|
a.log_warning('État invalide')
|
||||||
|
a.log_error('Échec chaudière')
|
||||||
|
a.log.assert_any_call('Demande à 40 °C', level='INFO', log='main_log', ascii_encode=False)
|
||||||
|
a.log.assert_any_call('État invalide', level='WARNING', log='main_log', ascii_encode=False)
|
||||||
|
a.log.assert_any_call('Échec chaudière', level='ERROR', log='error_log', ascii_encode=False)
|
||||||
|
|
||||||
|
def test_boiler_status_updates_action_without_advancing_regulation(self):
|
||||||
|
a = self.app()
|
||||||
|
a._control()
|
||||||
|
a._publish.reset_mock()
|
||||||
|
previous_time, integral = a.previous_time, a.controller.integral
|
||||||
|
for status, action in [(4, 'heating'), (14, 'idle'), (0, 'idle')]:
|
||||||
|
a._mqtt_event('', dict(topic='boiler/bai/Statenumber', payload={'value': status}), {})
|
||||||
|
states = [c.args[1] for c in a._publish.call_args_list if c.args[0] == a.base + '/state']
|
||||||
|
self.assertEqual(states[-1]['action'], action)
|
||||||
|
self.assertEqual(self.writes(a), [])
|
||||||
|
self.assertEqual(a.previous_time, previous_time)
|
||||||
|
self.assertEqual(a.controller.integral, integral)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
Reference in new issue
Block a user