From a2bff7f9c8f5d21a80dbf942b3913b4224cb38d1 Mon Sep 17 00:00:00 2001 From: Martin Pihrt Date: Mon, 31 Aug 2026 16:51:18 +0200 Subject: [PATCH 1/8] Expand Thermostat with cards and schedules --- CHANGELOG.md | 5 + plugins/thermostat/README.md | 22 +- plugins/thermostat/__init__.py | 355 ++++++++++-------- plugins/thermostat/model.py | 181 +++++++++ plugins/thermostat/plugin.json | 2 +- plugins/thermostat/static/thermostat.css | 103 +++++ plugins/thermostat/templates/thermostat.html | 304 +++++++-------- .../thermostat/templates/thermostat_help.html | 3 + tests/test_thermostat.py | 149 ++++++++ 9 files changed, 797 insertions(+), 327 deletions(-) create mode 100644 plugins/thermostat/model.py create mode 100644 plugins/thermostat/static/thermostat.css create mode 100644 tests/test_thermostat.py diff --git a/CHANGELOG.md b/CHANGELOG.md index abdd8d23..fbf4f547 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # OSPy-plugins Changelog +August 31 2026 +-------------- +(Martin Pihrt) - Thermostat v1.1.0
+Replaced the three fixed thermostat slots with create, edit and delete cards for up to 20 thermostats while retaining existing settings. Added optional per-card operating windows with continuous, daytime and overnight operation, exact boundary wakeups and program stopping when a window ends or a thermostat is disabled or deleted. Enforced one distinct OSPy program per enabled thermostat, added stable card identities, responsive styling, updated documentation and regression coverage. + August 30 2026 -------------- (Martin Pihrt) - Wind Speed Monitor v1.2.3 and Venetian Blind v1.2.8
diff --git a/plugins/thermostat/README.md b/plugins/thermostat/README.md index 0385a403..41d2b3b3 100644 --- a/plugins/thermostat/README.md +++ b/plugins/thermostat/README.md @@ -3,14 +3,16 @@ Thermostat Readme Tested in Python 3+ -The plug-in includes a `plugin.json` manifest and reports its worker, enabled -zones, current temperatures, unavailable sources or setup errors, active program -actions, latest cycle and errors through the OSPy system health interface. +The plug-in includes a `plugin.json` manifest and reports its worker, enabled thermostats, current temperatures, unavailable sources or setup errors, active program actions, latest cycle and errors through the OSPy system health interface. The thermostat plugin checks selected temperature sources and starts or stops selected OSPy programs when the configured temperature limits are reached. +Up to 20 thermostats can be created, edited and deleted as independent cards. Existing settings from the earlier three-slot version are retained automatically. + Each thermostat has a low temperature, a high temperature and one selected program. The low and high limits create hysteresis. For example, low 22.4 C and high 22.6 C means that no action is repeated while the temperature stays between these values. +Each enabled thermostat must use a different OSPy program. This prevents one thermostat from stopping a program controlled by another thermostat. + Temperature sources ----------- * Air Temperature DS probes @@ -20,16 +22,19 @@ Temperature sources Plugin setup ----------- * Use thermostat: - Enable or disable the whole plugin. + Enable or disable the whole plugin. Disabling the plugin stops programs assigned to enabled thermostats. * Show in footer: Show a short thermostat status in the OSPy home page footer. * Check interval: - How often the temperatures are checked. + How often the temperatures are checked. Operating time boundaries are handled independently of this interval. + +* Add thermostat / Save thermostat / Delete: + Create, edit or delete thermostat cards. Deleting an enabled thermostat stops its selected program. * Thermostat enabled: - Enable or disable one thermostat zone. + Enable or disable one thermostat. Disabling an active thermostat stops its selected program. * Temperature source: Select where the temperature is read from. @@ -41,7 +46,7 @@ Plugin setup Select which Shelly temperature value is used. This option is shown only for the Shelly Cloud source and is useful for Shelly devices with more temperature probes. For example, Temperature 3 means the third temperature value reported by the selected Shelly device. DS probes and OSPy sensors do not use this option. * Program: - Select the OSPy program controlled by this thermostat. + Select the OSPy program controlled by this thermostat. One program can be assigned to only one enabled thermostat. * Low temperature / Low action: When the temperature is equal or lower than the low value, the selected low action is executed once. @@ -49,6 +54,9 @@ Plugin setup * High temperature / High action: When the temperature is equal or higher than the high value, the selected high action is executed once. +* Limit operating time: + Leave disabled for continuous operation. Enable it to run the thermostat only from the selected start time up to, but not including, the selected end time. Overnight windows such as 22:00 to 06:00 are supported. The selected program is stopped when the operating window ends. + Note ----------- The plugin controls OSPy programs only. If you need to control a relay by URL, use an OSPy program together with the CLI Control plugin. diff --git a/plugins/thermostat/__init__.py b/plugins/thermostat/__init__.py index f5a81844..f40ca934 100644 --- a/plugins/thermostat/__init__.py +++ b/plugins/thermostat/__init__.py @@ -4,6 +4,7 @@ import json import time import traceback +import uuid from threading import Thread, Lock import web @@ -15,6 +16,7 @@ from ospy.stations import stations from ospy.webpages import ProtectedPage, showInFooter, clear_plugin_runtime_data from plugins import PluginOptions, plugin_url, get_runtime +from plugins.thermostat import model try: from ospy.sensors import sensors @@ -26,57 +28,12 @@ MENU = _('Package: Thermostat') LINK = 'settings_page' -THERMOSTAT_COUNT = 3 -INVALID_TEMPERATURE = -127 -MIN_CHECK_INTERVAL = 5 -MAX_CHECK_INTERVAL = 3600 ERROR_LOG_THROTTLE = 300 -SHELLY_VALUE_TYPES = [ - 'temperature', - 'temperature_2', - 'temperature_3', - 'temperature_4', - 'temperature_5', -] - -DEFAULT_ZONES = [ - { - 'enabled': False, - 'name': 'Thermostat 1', - 'source': 'air_temp', - 'channel': 0, - 'value_type': 'temperature', - 'low_temp': 22.4, - 'high_temp': 22.6, - 'low_action': 'start', - 'high_action': 'stop', - 'program': 0, - }, - { - 'enabled': False, - 'name': 'Thermostat 2', - 'source': 'air_temp', - 'channel': 1, - 'value_type': 'temperature', - 'low_temp': 22.4, - 'high_temp': 22.6, - 'low_action': 'start', - 'high_action': 'stop', - 'program': 0, - }, - { - 'enabled': False, - 'name': 'Thermostat 3', - 'source': 'air_temp', - 'channel': 2, - 'value_type': 'temperature', - 'low_temp': 22.4, - 'high_temp': 22.6, - 'low_action': 'start', - 'high_action': 'stop', - 'program': 0, - }, -] +MAX_THERMOSTATS = model.MAX_THERMOSTATS +INVALID_TEMPERATURE = model.INVALID_TEMPERATURE +MIN_CHECK_INTERVAL = model.MIN_CHECK_INTERVAL +MAX_CHECK_INTERVAL = model.MAX_CHECK_INTERVAL +SHELLY_VALUE_TYPES = model.SHELLY_VALUE_TYPES plugin_options = PluginOptions( NAME, @@ -84,7 +41,7 @@ 'enabled': False, 'check_interval': 30, 'use_footer': False, - 'zones': [dict(zone) for zone in DEFAULT_ZONES], + 'zones': [], } ) runtime = get_runtime() @@ -96,59 +53,28 @@ } -def _safe_int(value, default=0): - try: - return int(value) - except Exception: - return default - - -def _safe_float(value, default=0.0): - try: - return float(str(value).replace(',', '.')) - except Exception: - return default - - -def _clamp(value, minimum, maximum): - return max(minimum, min(maximum, value)) - - def _normalize_zones(): zones = plugin_options.get('zones', []) - if not isinstance(zones, list): - zones = [] - - normalized = [] - for index in range(THERMOSTAT_COUNT): - base = dict(DEFAULT_ZONES[index]) - if index < len(zones) and isinstance(zones[index], dict): - base.update(zones[index]) - base['enabled'] = bool(base.get('enabled', False)) - base['name'] = str(base.get('name') or _('Thermostat {}').format(index + 1)) - base['source'] = str(base.get('source') or 'air_temp') - if base['source'] not in ('air_temp', 'ospy_sensor', 'shelly_cloud'): - base['source'] = 'air_temp' - base['channel'] = max(0, _safe_int(base.get('channel'), 0)) - base['value_type'] = str(base.get('value_type') or 'temperature') - if base['value_type'] not in SHELLY_VALUE_TYPES: - base['value_type'] = 'temperature' - base['low_temp'] = _clamp(_safe_float(base.get('low_temp'), 22.4), -50, 100) - base['high_temp'] = _clamp(_safe_float(base.get('high_temp'), 22.6), -50, 100) - base['low_action'] = str(base.get('low_action') or 'start') - base['high_action'] = str(base.get('high_action') or 'stop') - if base['low_action'] not in ('none', 'start', 'stop'): - base['low_action'] = 'start' - if base['high_action'] not in ('none', 'start', 'stop'): - base['high_action'] = 'stop' - base['program'] = _clamp(_safe_int(base.get('program'), 0), 0, max(0, len(programs.get()) - 1)) - normalized.append(base) + normalized = model.normalize_zones( + zones, + len(programs.get()), + lambda: uuid.uuid4().hex, + lambda index: _('Thermostat {}').format(index + 1), + ) if normalized != zones: plugin_options['zones'] = normalized return normalized +def _safe_int(value, default=0): + return model.safe_int(value, default) + + +def _clamp(value, minimum, maximum): + return model.clamp(value, minimum, maximum) + + def source_title(source): titles = { 'air_temp': _('Air Temperature DS'), @@ -390,8 +316,8 @@ def __init__(self): self.daemon = True self._stop_event = runtime.stop_event self._sleep_time = 0 - self.zone_state = ['unknown'] * THERMOSTAT_COUNT - self.zone_temperatures = [None] * THERMOSTAT_COUNT + self.zone_state = {} + self.zone_temperatures = {} self.footer = None self._last_error_log = 0 self.start() @@ -429,14 +355,30 @@ def _log_problem(self, message): log.error(NAME, message) self._last_error_log = now + def _reconcile_zones(self, zones): + zone_ids = {zone['id'] for zone in zones} + self.zone_state = { + zone_id: self.zone_state.get(zone_id, 'unknown') + for zone_id in zone_ids + } + self.zone_temperatures = { + zone_id: self.zone_temperatures.get(zone_id) + for zone_id in zone_ids + } + def run(self): last_enabled = None while not self._stop_event.is_set(): try: - _normalize_zones() + zones = _normalize_zones() + self._reconcile_zones(zones) plugin_options['check_interval'] = _clamp(_safe_int(plugin_options.get('check_interval'), 30), MIN_CHECK_INTERVAL, MAX_CHECK_INTERVAL) if not plugin_options['enabled']: if last_enabled is not False: + if last_enabled is True: + for zone in zones: + if zone['enabled']: + stop_program(zone['program']) log.clear(NAME) log.info(NAME, _('Thermostat plug-in is disabled.')) self.update_footer(_('Disabled')) @@ -450,31 +392,55 @@ def run(self): last_enabled = True footer_parts = [] - for index, zone in enumerate(plugin_options['zones']): + now = time.localtime() + now_minutes = now.tm_hour * 60 + now.tm_min + duplicate_programs = model.duplicate_enabled_program_ids(zones) + handled_programs = set() + for zone in zones: + zone_id = zone['id'] + state = self.zone_state[zone_id] if not zone['enabled']: - self.zone_state[index] = 'disabled' - self.zone_temperatures[index] = None + self.zone_state[zone_id] = 'disabled' + self.zone_temperatures[zone_id] = None + continue + + if zone['program'] in duplicate_programs and zone['program'] in handled_programs: + if state != 'setup_error': + log.info(NAME, datetime_string() + ' ' + _('{} uses a program already assigned to another enabled thermostat.').format(zone['name'])) + self.zone_state[zone_id] = 'setup_error' + self.zone_temperatures[zone_id] = None + footer_parts.append('{} {}'.format(zone['name'], _('setup error'))) + continue + handled_programs.add(zone['program']) + + if not model.zone_in_time_window(zone, now_minutes): + if state != 'scheduled_off': + stopped = stop_program(zone['program']) + log.info(NAME, datetime_string() + ' ' + _('{} is outside its operating time. Program stop result: {}.').format(zone['name'], _('OK') if stopped else _('not changed'))) + self.zone_state[zone_id] = 'scheduled_off' + self.zone_temperatures[zone_id] = None + footer_parts.append('{} {}'.format(zone['name'], _('outside operating time'))) continue if zone['low_temp'] >= zone['high_temp']: - if self.zone_state[index] != 'setup_error': + if state != 'setup_error': log.info(NAME, datetime_string() + ' ' + _('{} has invalid temperature limits. Low temperature must be lower than high temperature.').format(zone['name'])) - self.zone_state[index] = 'setup_error' - self.zone_temperatures[index] = None + self.zone_state[zone_id] = 'setup_error' + self.zone_temperatures[zone_id] = None footer_parts.append('{} {}'.format(zone['name'], _('setup error'))) continue temperature = get_temperature(zone['source'], zone['channel'], zone['value_type']) if temperature == INVALID_TEMPERATURE: - if self.zone_state[index] != 'missing': + if state != 'missing': log.info(NAME, datetime_string() + ' ' + _('{} temperature is not available.').format(zone['name'])) - self.zone_state[index] = 'missing' - self.zone_temperatures[index] = None + self.zone_state[zone_id] = 'missing' + self.zone_temperatures[zone_id] = None footer_parts.append('{} ---'.format(zone['name'])) continue - self.zone_temperatures[index] = temperature + self.zone_temperatures[zone_id] = temperature - new_state = self.zone_state[index] + new_state = state action = None if temperature >= zone['high_temp']: new_state = 'high' @@ -482,19 +448,19 @@ def run(self): elif temperature <= zone['low_temp']: new_state = 'low' action = zone['low_action'] - elif self.zone_state[index] in ('unknown', 'disabled'): + elif state in ('unknown', 'disabled', 'scheduled_off'): new_state = 'hold' footer_parts.append('{} {:.1f}C'.format(zone['name'], temperature)) should_repeat_start = ( action == 'start' - and new_state == self.zone_state[index] + and new_state == state and not program_is_active(zone['program']) ) - if new_state != self.zone_state[index] or should_repeat_start: - self.zone_state[index] = new_state + if new_state != state or should_repeat_start: + self.zone_state[zone_id] = new_state if action and action != 'none': ok = execute_action(action, zone['program']) program_name = program_label(programs.get(zone['program'])) if program_exists(zone['program']) else _('Unknown program') @@ -510,7 +476,12 @@ def run(self): with health_lock: health_state['last_cycle'] = time.time() health_state['last_error_message'] = '' - self._sleep(_clamp(_safe_int(plugin_options.get('check_interval'), 30), MIN_CHECK_INTERVAL, MAX_CHECK_INTERVAL)) + sleep_time = _clamp(_safe_int(plugin_options.get('check_interval'), 30), MIN_CHECK_INTERVAL, MAX_CHECK_INTERVAL) + current = time.localtime() + boundary = model.seconds_until_boundary(current.tm_hour * 3600 + current.tm_min * 60 + current.tm_sec, zones) + if boundary is not None: + sleep_time = min(sleep_time, max(1, boundary)) + self._sleep(sleep_time) except Exception: self._log_problem(_('Thermostat plug-in') + ':\n' + traceback.format_exc()) self._sleep(60) @@ -543,33 +514,33 @@ def health(): state = dict(health_state) worker_running = checker is not None and checker.is_alive() zones = _normalize_zones() - enabled_indexes = [index for index, zone in enumerate(zones) if zone['enabled']] - zone_states = checker.zone_state if checker is not None else ['unknown'] * THERMOSTAT_COUNT - temperatures = ( - checker.zone_temperatures if checker is not None else [None] * THERMOSTAT_COUNT - ) + enabled_zones = [zone for zone in zones if zone['enabled']] + zone_states = checker.zone_state if checker is not None else {} + temperatures = checker.zone_temperatures if checker is not None else {} missing = sum( - 1 for index in enabled_indexes - if zone_states[index] in ('missing', 'setup_error') + 1 for zone in enabled_zones + if zone_states.get(zone['id'], 'unknown') in ('missing', 'setup_error') ) details = { _('Worker thread'): _('Running') if worker_running else _('Stopped'), _('Thermostat enabled'): _('Yes') if plugin_options['enabled'] else _('No'), - _('Enabled zones'): len(enabled_indexes), + _('Enabled zones'): len(enabled_zones), _('Zones with unavailable temperature or setup error'): missing, _('Active program actions'): sum( - 1 for index in enabled_indexes - if program_is_active(zones[index]['program']) + 1 for zone in enabled_zones + if program_is_active(zone['program']) ), _('Last successful cycle'): ( datetime_string(time.localtime(state['last_cycle'])) if state['last_cycle'] else _('Not available') ), } - for index in enabled_indexes: - details[zones[index]['name']] = ( - '{:.1f} C ({})'.format(temperatures[index], zone_states[index]) - if temperatures[index] is not None else zone_states[index] + for zone in enabled_zones: + zone_state = zone_states.get(zone['id'], 'unknown') + temperature = temperatures.get(zone['id']) + details[zone['name']] = ( + '{:.1f} C ({})'.format(temperature, zone_state) + if temperature is not None else zone_state ) if state['last_error_message']: details[_('Last error')] = state['last_error_message'] @@ -591,7 +562,7 @@ def health(): 'summary': state['last_error_message'], 'details': details, } - if not enabled_indexes: + if not enabled_zones: return { 'status': 'warning', 'summary': _('No thermostat zone is enabled.'), @@ -635,7 +606,7 @@ def mobile_cards(**_kwargs): def template_data(): - _normalize_zones() + zones = _normalize_zones() return { 'sources': [ ('air_temp', source_title('air_temp')), @@ -649,41 +620,119 @@ def template_data(): 'ospy_sensor': get_sensor_channel_names(), 'shelly_cloud': get_shelly_channel_names(), }, + 'max_thermostats': MAX_THERMOSTATS, + 'new_zone': model.default_zone(_('New thermostat')), + 'can_add': len(zones) < MAX_THERMOSTATS, } +def _zone_from_input(qdict, existing=None): + zones = _normalize_zones() + raw = dict(existing or model.default_zone(_('New thermostat'))) + raw.update({ + 'id': str(qdict.get('zone_id') or raw.get('id') or uuid.uuid4().hex), + 'enabled': 'enabled' in qdict, + 'name': str(qdict.get('name', raw['name'])).strip()[:120], + 'source': qdict.get('source', raw['source']), + 'channel': qdict.get('channel', raw['channel']), + 'value_type': qdict.get('value_type', raw['value_type']), + 'low_temp': qdict.get('low_temp', raw['low_temp']), + 'high_temp': qdict.get('high_temp', raw['high_temp']), + 'low_action': qdict.get('low_action', raw['low_action']), + 'high_action': qdict.get('high_action', raw['high_action']), + 'program': qdict.get('program', raw['program']), + 'time_limited': 'time_limited' in qdict, + 'start_time': qdict.get('start_time', raw['start_time']), + 'end_time': qdict.get('end_time', raw['end_time']), + }) + if not raw['name']: + raise ValueError(_('Enter a thermostat name.')) + if raw['time_limited'] and ( + not model.valid_time(str(raw['start_time'])) + or not model.valid_time(str(raw['end_time']))): + raise ValueError(_('Enter valid operating times.')) + zone = model.normalize_zone( + raw, raw['name'], len(programs.get()), lambda: uuid.uuid4().hex) + if not programs.get() and zone['enabled']: + raise ValueError(_('Create an OSPy program before enabling this thermostat.')) + try: + model.validate_zone(zone) + except ValueError as error: + if str(error) == 'invalid temperature limits': + raise ValueError(_('Low temperature must be lower than high temperature.')) + if str(error) == 'empty time window': + raise ValueError(_('Start and end time must be different when operating time is enabled.')) + raise ValueError(_('Enter valid operating times.')) + if model.duplicate_enabled_program(zones, zone): + raise ValueError(_('Each enabled thermostat must use a different program.')) + return zone + + class settings_page(ProtectedPage): """Load an html page for entering thermostat settings.""" def GET(self): - return self.plugin_render.thermostat(plugin_options, log.events(NAME), template_data()) + request = web.input(open='') + return self.plugin_render.thermostat( + plugin_options, log.events(NAME), template_data(), '', + str(request.get('open', ''))) def POST(self): qdict = web.input() verify_csrf(qdict) - zones = [] - for index, default_zone in enumerate(plugin_options['zones']): - zone = dict(default_zone) - zone['enabled'] = 'enabled{}'.format(index) in qdict - zone['name'] = qdict.get('name{}'.format(index), zone['name']) - zone['source'] = qdict.get('source{}'.format(index), zone['source']) - zone['channel'] = _safe_int(qdict.get('channel{}'.format(index), zone['channel']), zone['channel']) - zone['value_type'] = qdict.get('value_type{}'.format(index), zone['value_type']) - zone['low_temp'] = _safe_float(qdict.get('low_temp{}'.format(index), zone['low_temp']), zone['low_temp']) - zone['high_temp'] = _safe_float(qdict.get('high_temp{}'.format(index), zone['high_temp']), zone['high_temp']) - zone['low_action'] = qdict.get('low_action{}'.format(index), zone['low_action']) - zone['high_action'] = qdict.get('high_action{}'.format(index), zone['high_action']) - zone['program'] = _safe_int(qdict.get('program{}'.format(index), zone['program']), zone['program']) - zones.append(zone) - - plugin_options['enabled'] = 'enabled' in qdict - plugin_options['use_footer'] = 'use_footer' in qdict - plugin_options['check_interval'] = _clamp(_safe_int(qdict.get('check_interval', plugin_options['check_interval']), 30), MIN_CHECK_INTERVAL, MAX_CHECK_INTERVAL) - plugin_options['zones'] = zones - _normalize_zones() + default_action = 'save_zone' if qdict.get('form_kind') == 'zone' else 'save_settings' + action = str(qdict.get('action', default_action)) + open_zone = str(qdict.get('zone_id', '')) + try: + zones = _normalize_zones() + if action == 'save_settings': + was_enabled = plugin_options['enabled'] + plugin_options['enabled'] = 'enabled' in qdict + plugin_options['use_footer'] = 'use_footer' in qdict + plugin_options['check_interval'] = _clamp(_safe_int(qdict.get('check_interval', plugin_options['check_interval']), 30), MIN_CHECK_INTERVAL, MAX_CHECK_INTERVAL) + if was_enabled and not plugin_options['enabled']: + for zone in zones: + if zone['enabled']: + stop_program(zone['program']) + open_zone = '' + elif action == 'save_zone': + existing = next((zone for zone in zones if zone['id'] == open_zone), None) + if existing is None and len(zones) >= MAX_THERMOSTATS: + raise ValueError(_('A maximum of {} thermostats can be configured.').format(MAX_THERMOSTATS)) + saved = _zone_from_input(qdict, existing) + if existing is not None: + local_time = time.localtime() + now_minutes = local_time.tm_hour * 60 + local_time.tm_min + if existing['enabled'] and ( + not saved['enabled'] + or existing['program'] != saved['program'] + or not model.zone_in_time_window(saved, now_minutes)): + stop_program(existing['program']) + zones[zones.index(existing)] = saved + else: + zones.append(saved) + plugin_options['zones'] = zones + open_zone = saved['id'] + elif action == 'delete_zone': + existing = next((zone for zone in zones if zone['id'] == open_zone), None) + if existing is not None: + if existing['enabled']: + stop_program(existing['program']) + plugin_options['zones'] = [zone for zone in zones if zone['id'] != open_zone] + open_zone = '' + else: + raise ValueError(_('Unknown thermostat settings action.')) + except ValueError as error: + web.ctx.status = '400 Bad Request' + return self.plugin_render.thermostat( + plugin_options, log.events(NAME), template_data(), str(error), + open_zone) if checker is not None: checker.update() - raise web.seeother(plugin_url(settings_page), True) + target = plugin_url(settings_page) + if open_zone: + target += '?open=' + open_zone + raise web.seeother(target, True) class help_page(ProtectedPage): diff --git a/plugins/thermostat/model.py b/plugins/thermostat/model.py new file mode 100644 index 00000000..f6992de0 --- /dev/null +++ b/plugins/thermostat/model.py @@ -0,0 +1,181 @@ +# -*- coding: utf-8 -*- +"""Pure configuration and scheduling helpers for the Thermostat plug-in.""" + +import re + + +MAX_THERMOSTATS = 20 +INVALID_TEMPERATURE = -127 +MIN_CHECK_INTERVAL = 5 +MAX_CHECK_INTERVAL = 3600 +SHELLY_VALUE_TYPES = ( + 'temperature', + 'temperature_2', + 'temperature_3', + 'temperature_4', + 'temperature_5', +) +SOURCES = ('air_temp', 'ospy_sensor', 'shelly_cloud') +ACTIONS = ('none', 'start', 'stop') +TIME_PATTERN = re.compile(r'^(?:[01]\d|2[0-3]):[0-5]\d$') + + +def safe_int(value, default=0): + try: + return int(value) + except Exception: + return default + + +def safe_float(value, default=0.0): + try: + return float(str(value).replace(',', '.')) + except Exception: + return default + + +def clamp(value, minimum, maximum): + return max(minimum, min(maximum, value)) + + +def default_zone(name='Thermostat'): + return { + 'id': '', + 'enabled': False, + 'name': name, + 'source': 'air_temp', + 'channel': 0, + 'value_type': 'temperature', + 'low_temp': 22.4, + 'high_temp': 22.6, + 'low_action': 'start', + 'high_action': 'stop', + 'program': 0, + 'time_limited': False, + 'start_time': '06:00', + 'end_time': '22:00', + } + + +def valid_time(value): + return isinstance(value, str) and TIME_PATTERN.match(value) is not None + + +def time_minutes(value): + if not valid_time(value): + raise ValueError('invalid time') + hour, minute = value.split(':') + return int(hour) * 60 + int(minute) + + +def normalize_zone(value, name, program_count, id_factory): + zone = default_zone(name) + if isinstance(value, dict): + zone.update(value) + zone['id'] = str(zone.get('id') or id_factory()) + zone['enabled'] = bool(zone.get('enabled', False)) + zone['name'] = str(zone.get('name') or name) + zone['source'] = str(zone.get('source') or 'air_temp') + if zone['source'] not in SOURCES: + zone['source'] = 'air_temp' + zone['channel'] = max(0, safe_int(zone.get('channel'), 0)) + zone['value_type'] = str(zone.get('value_type') or 'temperature') + if zone['value_type'] not in SHELLY_VALUE_TYPES: + zone['value_type'] = 'temperature' + zone['low_temp'] = clamp(safe_float(zone.get('low_temp'), 22.4), -50, 100) + zone['high_temp'] = clamp(safe_float(zone.get('high_temp'), 22.6), -50, 100) + zone['low_action'] = str(zone.get('low_action') or 'start') + zone['high_action'] = str(zone.get('high_action') or 'stop') + if zone['low_action'] not in ACTIONS: + zone['low_action'] = 'start' + if zone['high_action'] not in ACTIONS: + zone['high_action'] = 'stop' + zone['program'] = clamp(safe_int(zone.get('program'), 0), 0, max(0, program_count - 1)) + zone['time_limited'] = bool(zone.get('time_limited', False)) + zone['start_time'] = str(zone.get('start_time') or '06:00') + zone['end_time'] = str(zone.get('end_time') or '22:00') + if not valid_time(zone['start_time']): + zone['start_time'] = '06:00' + if not valid_time(zone['end_time']): + zone['end_time'] = '22:00' + return zone + + +def normalize_zones(values, program_count, id_factory, name_factory): + if not isinstance(values, list): + values = [] + normalized = [] + used_ids = set() + for index, value in enumerate(values[:MAX_THERMOSTATS]): + if not isinstance(value, dict): + continue + zone = normalize_zone(value, name_factory(index), program_count, id_factory) + if zone['id'] in used_ids: + zone['id'] = str(id_factory()) + used_ids.add(zone['id']) + normalized.append(zone) + return normalized + + +def validate_zone(zone): + if zone['low_temp'] >= zone['high_temp']: + raise ValueError('invalid temperature limits') + if zone['time_limited']: + start = time_minutes(zone['start_time']) + end = time_minutes(zone['end_time']) + if start == end: + raise ValueError('empty time window') + + +def duplicate_enabled_program(zones, candidate): + if not candidate.get('enabled'): + return False + return any( + zone.get('enabled') + and zone.get('id') != candidate.get('id') + and zone.get('program') == candidate.get('program') + for zone in zones + ) + + +def duplicate_enabled_program_ids(zones): + duplicates = set() + used = set() + for zone in zones: + if not zone.get('enabled'): + continue + program = zone.get('program') + if program in used: + duplicates.add(program) + used.add(program) + return duplicates + + +def in_time_window(now_minutes, start_minutes, end_minutes): + if start_minutes == end_minutes: + return False + if start_minutes < end_minutes: + return start_minutes <= now_minutes < end_minutes + return now_minutes >= start_minutes or now_minutes < end_minutes + + +def zone_in_time_window(zone, now_minutes): + if not zone.get('time_limited'): + return True + return in_time_window( + now_minutes, + time_minutes(zone['start_time']), + time_minutes(zone['end_time']), + ) + + +def seconds_until_boundary(now_seconds, zones): + boundaries = [] + for zone in zones: + if not zone.get('enabled') or not zone.get('time_limited'): + continue + for key in ('start_time', 'end_time'): + boundary = time_minutes(zone[key]) * 60 + distance = (boundary - now_seconds) % 86400 + boundaries.append(distance if distance else 86400) + return min(boundaries) if boundaries else None diff --git a/plugins/thermostat/plugin.json b/plugins/thermostat/plugin.json index e9e01ee2..d65053c9 100644 --- a/plugins/thermostat/plugin.json +++ b/plugins/thermostat/plugin.json @@ -2,7 +2,7 @@ "schema_version": 1, "id": "thermostat", "name": "Thermostat", - "version": "1.0.1", + "version": "1.1.0", "author": "Martin Pihrt", "license": "GPL-3.0", "ospy": { diff --git a/plugins/thermostat/static/thermostat.css b/plugins/thermostat/static/thermostat.css new file mode 100644 index 00000000..f5a5fd21 --- /dev/null +++ b/plugins/thermostat/static/thermostat.css @@ -0,0 +1,103 @@ +.thermostatCard { + border: 1px solid #999; + border-radius: 6px; + margin: 10px 0; + padding: 0 10px 10px; +} + +.thermostatCard summary { + cursor: pointer; + display: flex; + font-weight: bold; + justify-content: space-between; + padding: 10px 0; +} + +.thermostatCardState { + font-weight: normal; +} + +.thermostatActions { + display: flex; + gap: 8px; + margin-top: 10px; +} + +.thermostatError { + background: #b21f2d; + color: #fff; + margin: 10px 0; + padding: 8px; +} + +.thermostatLog { + box-sizing: border-box; + font-family: monospace; + max-width: 100%; + width: 100%; +} + +.thermostatSwitch { + cursor: pointer; + display: inline-block; + height: 24px; + position: relative; + width: 50px; +} + +.thermostatSwitch input { + height: 1px; + opacity: 0; + position: absolute; + width: 1px; +} + +.thermostatSlider { + background-color: #ff4d4d; + border-radius: 34px; + bottom: 0; + left: 0; + position: absolute; + right: 0; + top: 0; + transition: .4s; +} + +.thermostatSlider:before { + background-color: #fff; + border-radius: 50%; + bottom: 3px; + content: ""; + height: 18px; + left: 4px; + position: absolute; + transition: .4s; + width: 18px; +} + +.thermostatSwitch input:checked + .thermostatSlider { + background-color: #4caf50; +} + +.thermostatSwitch input:checked + .thermostatSlider:before { + transform: translateX(24px); +} + +.thermostatSwitch input:focus-visible + .thermostatSlider { + outline: 2px solid currentColor; + outline-offset: 2px; +} + +@media (max-width: 700px) { + .thermostatForm table, + .thermostatForm tbody, + .thermostatForm tr, + .thermostatForm td, + .thermostatGeneral table, + .thermostatGeneral tbody, + .thermostatGeneral tr, + .thermostatGeneral td { + display: block; + width: 100%; + } +} diff --git a/plugins/thermostat/templates/thermostat.html b/plugins/thermostat/templates/thermostat.html index aca1ae94..eb5f4cea 100644 --- a/plugins/thermostat/templates/thermostat.html +++ b/plugins/thermostat/templates/thermostat.html @@ -1,201 +1,173 @@ -$def with(plugin_options, events, data) +$def with(plugin_options, events, data, error='', open_zone='') $var title: $_('Thermostat') $var page: plugins - +
$_('Thermostat')
$_('Help')
-
+ + $if error: +
$error
+ + $:csrf_input() + - + - + - - - - $for i, zone in enumerate(plugin_options['zones']): - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +
$_('Use thermostat'):$_('Use thermostat'): - $_('Show in footer'):$_('Show in footer'): -
$_('Check interval'): - $_('seconds') -
$_('Thermostat') ${i + 1}
$_('Enabled'): - - $_('Name'): - -
$_('Temperature source'): - - $_('Channel'): - - -
$_('Shelly temperature'): - - $_('Program'): - -
$_('Low temperature'): - °C - $_('Low action'): - -
$_('High temperature'): - °C - $_('High action'): - -
$_('Status'): - + $_('Check interval'): + $_('seconds')
+ +

$_('Thermostats')

+ $if not plugin_options['zones']: +

$_('No thermostat is configured. Add the first thermostat below.')

+ + $ zones_to_render = list(plugin_options['zones']) + $if data['can_add']: + $ zones_to_render.append(data['new_zone']) + $for zone in zones_to_render: + $ is_new = not zone.get('id') +
+ + ${_('New thermostat') if is_new else zone['name']} + ${_('Enabled') if zone.get('enabled') else _('Disabled')} · ${_('Add') if is_new else _('Edit')} + +
+ $:csrf_input() + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
$_('Enabled'):$_('Name'):
$_('Temperature source'): + + $_('Channel'): + + +
$_('Shelly temperature'): + +
$_('Program'): + +
$_('Low temperature'): °C$_('Low action'):
$_('High temperature'): °C$_('High action'):
$_('Limit operating time'):
$_('Operating time from'):$_('Operating time to'):
+
+ + $if not is_new: + +
+
+
+ + $if not data['can_add']: +

$_('The maximum of 20 thermostats has been reached.')

+ +

$_('Status')

+
+
-
diff --git a/plugins/thermostat/templates/thermostat_help.html b/plugins/thermostat/templates/thermostat_help.html index df4e99fd..0eb9ca66 100644 --- a/plugins/thermostat/templates/thermostat_help.html +++ b/plugins/thermostat/templates/thermostat_help.html @@ -6,9 +6,12 @@
$_('Thermostat - help')

$_('The thermostat plugin checks selected temperature sources and starts or stops selected OSPy programs when the configured temperature limits are reached.')

+

$_('Create, edit or delete up to 20 thermostat cards. Existing settings from the earlier three-slot version are retained automatically.')

$_('Use low and high temperature values with one decimal place to create hysteresis. For example, low 22.4 C and high 22.6 C means that no action is repeated while the temperature stays between these values.')

$_('Temperature can be read from the Air Temperature DS probes, OSPy sensors, or Shelly Cloud values when the related plugin is configured.')

$_('The Shelly temperature selector is used only for Shelly Cloud devices with more temperature values. For example, Temperature 3 means the third temperature value reported by the selected Shelly device.')

+

$_('Each enabled thermostat must use a different OSPy program. Disabling or deleting a thermostat stops its selected program.')

+

$_('Leave Limit operating time disabled for continuous operation. Enable it to run the thermostat only from the start time up to, but not including, the end time. Overnight windows such as 22:00 to 06:00 are supported, and the selected program is stopped when the operating window ends.')

$_('Programs can then be used to control stations, CLI actions, or external relays such as Shelly devices.')

diff --git a/tests/test_thermostat.py b/tests/test_thermostat.py new file mode 100644 index 00000000..e847102d --- /dev/null +++ b/tests/test_thermostat.py @@ -0,0 +1,149 @@ +import importlib.util +import json +import pathlib +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +PLUGIN = ROOT / 'plugins' / 'thermostat' +SPEC = importlib.util.spec_from_file_location( + 'thermostat_model', PLUGIN / 'model.py') +model = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(model) + + +class ThermostatModelTests(unittest.TestCase): + def test_legacy_zones_gain_stable_fields_without_fixed_padding(self): + legacy = [{ + 'enabled': True, + 'name': 'Boiler room', + 'source': 'air_temp', + 'channel': 2, + 'low_temp': 20.1, + 'high_temp': 20.5, + 'low_action': 'start', + 'high_action': 'stop', + 'program': 1, + }] + ids = iter(('stable-id',)) + zones = model.normalize_zones( + legacy, 3, lambda: next(ids), + lambda index: 'Thermostat {}'.format(index + 1)) + self.assertEqual(len(zones), 1) + self.assertEqual(zones[0]['id'], 'stable-id') + self.assertFalse(zones[0]['time_limited']) + self.assertEqual(zones[0]['start_time'], '06:00') + self.assertEqual(zones[0]['end_time'], '22:00') + self.assertEqual(zones[0]['channel'], 2) + + def test_normalization_limits_cards_and_repairs_duplicate_ids(self): + stored = [] + for index in range(25): + zone = model.default_zone('Thermostat {}'.format(index + 1)) + zone['id'] = 'duplicate' + stored.append(zone) + counter = iter('id-{}'.format(index) for index in range(30)) + zones = model.normalize_zones( + stored, 30, lambda: next(counter), + lambda index: 'Thermostat {}'.format(index + 1)) + self.assertEqual(len(zones), model.MAX_THERMOSTATS) + self.assertEqual(len({zone['id'] for zone in zones}), model.MAX_THERMOSTATS) + + def test_time_window_supports_daytime_and_overnight_boundaries(self): + self.assertTrue(model.in_time_window(8 * 60, 8 * 60, 20 * 60)) + self.assertTrue(model.in_time_window(19 * 60 + 59, 8 * 60, 20 * 60)) + self.assertFalse(model.in_time_window(20 * 60, 8 * 60, 20 * 60)) + self.assertTrue(model.in_time_window(22 * 60, 22 * 60, 6 * 60)) + self.assertTrue(model.in_time_window(5 * 60 + 59, 22 * 60, 6 * 60)) + self.assertFalse(model.in_time_window(6 * 60, 22 * 60, 6 * 60)) + self.assertFalse(model.in_time_window(12 * 60, 12 * 60, 12 * 60)) + + def test_continuous_zone_ignores_operating_times(self): + zone = model.default_zone('Continuous') + zone['start_time'] = '10:00' + zone['end_time'] = '11:00' + self.assertTrue(model.zone_in_time_window(zone, 23 * 60)) + + def test_equal_or_invalid_limited_times_are_rejected(self): + zone = model.default_zone('Limited') + zone['time_limited'] = True + zone['start_time'] = '06:00' + zone['end_time'] = '06:00' + with self.assertRaises(ValueError): + model.validate_zone(zone) + self.assertFalse(model.valid_time('24:00')) + self.assertFalse(model.valid_time('8:00')) + + def test_next_boundary_is_independent_of_check_interval(self): + zone = model.default_zone('Day') + zone.update({ + 'enabled': True, + 'time_limited': True, + 'start_time': '08:00', + 'end_time': '20:00', + }) + self.assertEqual( + model.seconds_until_boundary(19 * 3600 + 59 * 60 + 30, [zone]), + 30, + ) + self.assertEqual( + model.seconds_until_boundary(20 * 3600, [zone]), + 12 * 3600, + ) + zone['time_limited'] = False + self.assertIsNone(model.seconds_until_boundary(0, [zone])) + + def test_only_enabled_thermostats_must_use_distinct_programs(self): + first = model.default_zone('First') + first.update({'id': 'first', 'enabled': True, 'program': 2}) + second = model.default_zone('Second') + second.update({'id': 'second', 'enabled': True, 'program': 2}) + self.assertTrue(model.duplicate_enabled_program([first], second)) + self.assertEqual(model.duplicate_enabled_program_ids([first, second]), {2}) + second['enabled'] = False + self.assertFalse(model.duplicate_enabled_program([first], second)) + self.assertEqual(model.duplicate_enabled_program_ids([first, second]), set()) + + def test_invalid_temperature_hysteresis_is_rejected(self): + zone = model.default_zone('Invalid') + zone['low_temp'] = 22.6 + zone['high_temp'] = 22.4 + with self.assertRaises(ValueError): + model.validate_zone(zone) + + +class ThermostatInterfaceTests(unittest.TestCase): + def test_settings_use_crud_cards_time_inputs_and_slider_switches(self): + template = (PLUGIN / 'templates' / 'thermostat.html').read_text( + encoding='utf-8') + self.assertIn('value="save_zone"', template) + self.assertIn('value="delete_zone"', template) + self.assertIn("_('Add thermostat')", template) + self.assertIn("_('Edit')", template) + self.assertIn('name="time_limited" type="checkbox"', template) + self.assertIn('name="start_time" type="time"', template) + self.assertIn('name="end_time" type="time"', template) + self.assertIn('class="thermostatSlider"', template) + self.assertIn('/plugins/thermostat/static/thermostat.css?v=1.1.0', template) + self.assertNotIn('