diff --git a/CHANGELOG.md b/CHANGELOG.md index abdd8d23..277a9f81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # OSPy-plugins Changelog +September 2 2026 +---------------- +(Martin Pihrt) - System Update v1.2.8
+Forced GitHub fetch operations to use HTTP/1.1, avoiding HTTP/2 transport failures that returned misleading HTTP 401 and `expected flush after ref listing` errors for public repositories. Required command failures and timeouts now preserve the command, exit status and Git output in diagnostics instead of reporting only a generic exit code 128. Added regression coverage for the fetch transport and error reporting on both stable and beta update channels. + +August 31 2026 +-------------- +(Martin Pihrt) - Irrigation Safety v1.0.0
+Added a dedicated irrigation protection plug-in over cached Water Meter, Pressure Monitor and tank-provider data. It provides per-station flow profiles, robust one-station automatic learning, combined ranges for simultaneous stations, startup and confirmation timing, unexpected-flow, pressure, tank and unavailable-data incidents, Off/Monitor only/Active protection modes, guarded output shutdown, scheduler disabling, persistent administrator acknowledgement, bounded temporary bypass, asynchronous e-mail and mobile push delivery, live background page updates, health/mobile/provider contributions, documentation and regression coverage. Active protection is never enabled by default. + +(Martin Pihrt) - Thermostat v1.1.1
+Moved the enabled or disabled state directly behind each thermostat name with green or red text and replaced the plain Edit or Add caption with a colored button-style card control. + +(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/README.md b/README.md index 6f9b3114..a3c9c853 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,8 @@ Water Meter v1.2.1 uses uninterrupted one-second measurements and separates its Automation Rules v1.1.3 also discovers concrete provider actions from Venetian Blind 1.2.7, Water Tank Monitor 1.1.1, Current Loop Tanks Monitor 1.1.1 and OSPy Backup 1.0.5. Rules can operate one configured blind, stop regulation belonging to a selected tank or all tanks, reset the ultrasonic tank extrema and create a plug-in data backup through each provider's existing guarded implementation. OSPy Backup also declares native mobile creation and download of its latest plug-in-data ZIP, while Venetian Blind exposes the same per-blind movement and configured tilt actions on mobile cards. +Irrigation Safety v1.0.0 is a dedicated protection layer over Water Meter, optional Pressure Monitor and optional tank providers. It learns a robust expected flow range for each OSPy station while that station runs alone, combines configured ranges for simultaneous stations and detects missing, low, high or unexpected flow, missing pressure, low tank level and unavailable measurements. Off, Monitor only and Active protection modes support safe commissioning. A confirmed live incident is recorded before asynchronous e-mail and mobile push delivery; Active protection can immediately stop all outputs, disable the scheduler and enforce a persistent lock until an administrator acknowledges the cleared fault. A bounded temporary bypass suppresses control actions without stopping monitoring. The responsive page uses OSPy slider switches and cards and refreshes measurements, active stations, learning progress, incidents and history in the background. + Automation Rules v1.1.2 builds collapsible responsive graphical AND/OR conditions over cached `ospy.provider.v1` values, enabled built-in OSPy sensors from Pihrt or Shelly, local date/time ranges and read-only OSPy operating states. OSPy conditions include scheduler and operating modes, water-level adjustment, rain delay, rain-sensor state and cached system or plug-in update availability without starting a network check. Ultrasonic sensors provide distance, water level, fill and volume conditions. Trigger, repeat and recovery messages include actual values, operators and limits. Browser permission performs an immediate visible test, Firefox and other capable browsers use Service Worker delivery first, the direct Notification API remains a fallback, and client delivery errors are shown on the settings page. It also supports confirmation and repeat intervals, recovery notices, a separate default-on test state, bounded action history, Home popups, e-mail, Telegram and mobile push. A separate default-off control switch enables actions that stop stations, outputs or programs, disable the scheduler, operate a specific output once or in a bounded run/pause cycle, change the water level or invoke a declared provider action. Every cycle start, pause, resumed run, failure and final stop is recorded with its reason. Water Meter 1.2.1 supplies the first concrete provider action by allowing a rule to reset its accumulated counters. Cycles continue only while conditions are available and matched and stop safely when configuration, manual mode or control permission changes. Test mode only simulates actions, while optional incident locking requires safe administrator acknowledgement after conditions become normal. Version 1.1.2 requires OSPy 3.0.354 or newer so the core control contract is installed before this plug-in. Energy Meter v1.0.6 monitors multiple Shelly Pro 3EM and Shelly 3EM-63T Gen3 meters over direct local RPC by IP address or DNS name, or optionally through Shelly Cloud Integration. It recognizes configured Integrator devices while their first reading is still loading and retries only those meters every five seconds without producing a failure traceback or changing other meter intervals. Grid, Solar production, Load and Auxiliary roles keep import, export and generation distinct; phase and total history supports restart-safe counters, meter replacement, time tariffs, costs and feed-in income in every overview period, a selectable retained calendar day, photovoltaic self-consumption and savings, append-only local history or optional SQL, CSV, separate energy and power graphs, explicitly documented history deletion, Home, diagnostics and mobile API cards. See [the Energy Meter README](plugins/energy_meter/README.md) for configuration and formulas. @@ -62,6 +64,7 @@ Venetian Blind v1.2.7 manages blinds individually in list or card form, stores o Available plugins: * Automation Rules +* Irrigation Safety * Usage Statistics * LCD Display * Pressure Monitor diff --git a/plugins/irrigation_safety/README.md b/plugins/irrigation_safety/README.md new file mode 100644 index 00000000..f61354b3 --- /dev/null +++ b/plugins/irrigation_safety/README.md @@ -0,0 +1,28 @@ +# Irrigation Safety + +Irrigation Safety is a dedicated protection layer for OSPy. It combines live OSPy output state with cached `ospy.provider.v1` data and never performs an extra hardware read. + +Version 1.0.0 provides: + +- Off, Monitor only and Active protection modes. Monitor only is the safe commissioning mode. +- A separate flow profile for every enabled non-master OSPy station, including startup delay and fault-confirmation time. +- Combined expected ranges when several fully configured stations run at once. +- Detection of no, low or high flow during irrigation and flow while no irrigation station is active. +- Optional pressure and tank-level protection, including unavailable or stale measurement incidents. +- One-shot automatic learning from Water Meter. Learning accepts samples only after startup delay and while exactly one selected station is active. A robust P10/P90 range expanded by a configurable margin is saved when enough samples have been collected. +- Guarded shutdown that clears current runs and outputs, switches off the separate master relay and optionally disables the scheduler. +- Persistent incident locking. A locked incident is enforced continuously and can be acknowledged only after its condition is no longer active. The scheduler is never re-enabled automatically. +- A bounded temporary bypass that suppresses control actions while monitoring, incident history and notifications continue. +- E-mail delivery through either official e-mail plug-in and immediate mobile push through OSPy's asynchronous push dispatcher. Notification work is kept outside the safety evaluation worker. +- A responsive page styled after Thermostat and Automation Rules, using OSPy buttons and slider switches. Current values, active stations, learning progress, incidents and history update every two seconds without refreshing the page. +- Health, mobile-card and read-only `ospy.provider.v1` contributions. + +## Safety workflow + +1. Install and enable Water Meter. +2. Select **Monitor only**. +3. Enable a station profile and either enter verified flow limits or start automatic learning, then run that station by itself. +4. Test every configured station and notification channel. +5. Select **Active protection** only after the displayed limits and startup delays have been verified on the actual irrigation hardware. + +If a confirmed fault occurs in Active protection, output shutdown is executed before e-mail or push delivery. A notification outage therefore cannot delay valve or pump shutdown. diff --git a/plugins/irrigation_safety/__init__.py b/plugins/irrigation_safety/__init__.py new file mode 100644 index 00000000..17d03f62 --- /dev/null +++ b/plugins/irrigation_safety/__init__.py @@ -0,0 +1,1073 @@ +# -*- coding: utf-8 -*- +"""Independent flow, pressure and tank safety for OSPy irrigation outputs.""" + +import datetime +import json +import queue +import time +import traceback +import uuid +from threading import Lock, RLock, Thread + +import web + +import plugins as ospy_plugins +from ospy.helpers import datetime_string, verify_csrf +from ospy.log import log +from ospy.options import options +from ospy.programs import programs +from ospy.runonce import run_once +from ospy.stations import stations +from ospy.webpages import ProtectedPage, clear_plugin_runtime_data, showInFooter +from plugins import PluginOptions, get, get_runtime, plugin_url, running +from plugins.irrigation_safety import model + + +NAME = 'Irrigation Safety' +MENU = _('Package: Irrigation Safety') +LINK = 'settings_page' +ERROR_LOG_THROTTLE = 300 + + +plugin_options = PluginOptions(NAME, { + 'mode': 'off', + 'check_interval': 2, + 'data_timeout': 10, + 'history_limit': 500, + 'use_footer': True, + 'send_email': True, + 'send_push': True, + 'notify_recovery': True, + 'stop_all_outputs': True, + 'disable_scheduler': True, + 'latch_incidents': True, + 'unexpected_flow_enabled': True, + 'unexpected_flow_lpm': 0.5, + 'unexpected_flow_confirm': 20, + 'pressure_enabled': False, + 'pressure_confirm': 10, + 'tank_enabled': False, + 'tank_provider_id': 'tank_monitor', + 'tank_resource_id': 'main', + 'tank_value_id': 'fill_percent', + 'tank_minimum': 10.0, + 'tank_confirm': 10, + 'profiles': [], + 'incidents': {}, + 'history': [], + 'bypass_until': 0, +}) +runtime = get_runtime() +state_lock = RLock() +health_lock = Lock() +health_state = { + 'last_cycle': 0, + 'last_error': 0, + 'last_error_message': '', + 'provider_errors': 0, + 'safety_actions': 0, + 'notification_errors': 0, +} + + +def _safe_int(value, default=0): + return model.safe_int(value, default) + + +def _safe_float(value, default=0.0): + return model.safe_float(value, default) + + +def _mode(): + value = str(plugin_options.get('mode', 'off')) + return value if value in model.MODES else 'off' + + +def _station_rows(): + return [ + (station.index, station.name) + for station in stations.get() + if station.enabled and not station.is_master and + not station.is_master_two and not station.is_master_by_program + ] + + +def _profiles(save=True): + current = plugin_options.get('profiles', []) + normalized = model.normalize_profiles(current, _station_rows()) + if save and normalized != current: + plugin_options['profiles'] = normalized + return normalized + + +def _profile(station_id): + return next((item for item in _profiles() + if int(item['station_id']) == int(station_id)), None) + + +def _save_profile(updated): + with state_lock: + profiles = _profiles(save=False) + for index, profile in enumerate(profiles): + if int(profile['station_id']) == int(updated['station_id']): + profiles[index] = updated + break + plugin_options['profiles'] = profiles + + +def _profile_from_input(qdict, current): + updated = dict(current) + updated.update({ + 'enabled': qdict.get('enabled') == 'on', + 'minimum_flow_lpm': _safe_float( + qdict.get('minimum_flow_lpm'), current['minimum_flow_lpm']), + 'maximum_flow_lpm': _safe_float( + qdict.get('maximum_flow_lpm'), current['maximum_flow_lpm']), + 'startup_delay': _safe_int( + qdict.get('startup_delay'), current['startup_delay']), + 'confirm_seconds': _safe_int( + qdict.get('confirm_seconds'), current['confirm_seconds']), + 'learning_samples': _safe_int( + qdict.get('learning_samples'), current['learning_samples']), + 'learning_tolerance_percent': _safe_float( + qdict.get('learning_tolerance_percent'), + current['learning_tolerance_percent']), + 'learning_minimum_margin_lpm': _safe_float( + qdict.get('learning_minimum_margin_lpm'), + current['learning_minimum_margin_lpm']), + }) + updated = model.normalize_profile( + updated, current['station_id'], current['name']) + try: + model.validate_profile(updated) + except ValueError: + raise ValueError(_('Minimum flow must be lower than maximum flow.')) + return updated + + +def _normalize_settings(): + plugin_options['mode'] = _mode() + plugin_options['check_interval'] = model.clamp( + _safe_int(plugin_options.get('check_interval'), 2), + model.MIN_CHECK_INTERVAL, model.MAX_CHECK_INTERVAL) + plugin_options['data_timeout'] = model.clamp( + _safe_int(plugin_options.get('data_timeout'), 10), 3, 3600) + plugin_options['history_limit'] = model.clamp( + _safe_int(plugin_options.get('history_limit'), 500), 10, model.MAX_HISTORY) + plugin_options['unexpected_flow_lpm'] = model.clamp( + _safe_float(plugin_options.get('unexpected_flow_lpm'), 0.5), + 0.01, 1000000.0) + plugin_options['unexpected_flow_confirm'] = model.clamp( + _safe_int(plugin_options.get('unexpected_flow_confirm'), 20), 1, 3600) + plugin_options['pressure_confirm'] = model.clamp( + _safe_int(plugin_options.get('pressure_confirm'), 10), 1, 3600) + plugin_options['tank_minimum'] = model.clamp( + _safe_float(plugin_options.get('tank_minimum'), 10.0), 0.0, 1000000.0) + plugin_options['tank_confirm'] = model.clamp( + _safe_int(plugin_options.get('tank_confirm'), 10), 1, 3600) + plugin_options['bypass_until'] = max( + 0, _safe_int(plugin_options.get('bypass_until'), 0)) + _profiles() + + +def _iso_epoch(value): + try: + text = str(value or '') + if text.endswith('Z'): + text = text[:-1] + '+00:00' + parsed = datetime.datetime.fromisoformat(text) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=datetime.timezone.utc) + return parsed.timestamp() + except (TypeError, ValueError, OverflowError): + return 0 + + +def _snapshot_value(snapshots, provider_id, resource_id, value_id, now=None): + now = time.time() if now is None else now + provider = snapshots.get('providers', {}).get(provider_id) + if not isinstance(provider, dict) or provider.get('status') != 'ok': + return None, '', 'unavailable' + resource = next((item for item in provider.get('resources', []) + if str(item.get('id')) == str(resource_id)), None) + if not resource or resource.get('status') != 'ok': + return None, '', 'unavailable' + value = next((item for item in resource.get('values', []) + if str(item.get('id')) == str(value_id)), None) + if not value or value.get('value') is None: + return None, '', 'unavailable' + observed_at = value.get('observed_at') or resource.get('observed_at') or provider.get('observed_at') + observed_epoch = _iso_epoch(observed_at) + if not observed_epoch or now - observed_epoch > plugin_options['data_timeout']: + return None, value.get('unit', ''), 'stale' + return value.get('value'), value.get('unit', ''), 'ok' + + +def _active_station_ids(): + active = stations.active() + return [ + station.index for station in stations.get() + if station.index < len(active) and active[station.index] and station.enabled and + not station.is_master and not station.is_master_two and + not station.is_master_by_program + ] + + +def _master_active(snapshots, now): + value, _unit, status = _snapshot_value( + snapshots, 'pressure_monitor', 'main', 'master_active', now) + if status == 'ok': + return bool(value), status + master_ids = [value for value in ( + getattr(stations, 'master', None), getattr(stations, 'master_two', None), + getattr(stations, 'master_by_program', None)) if isinstance(value, int)] + return any(stations.active(value) for value in master_ids), status + + +def _provider_snapshots(): + collector = getattr(ospy_plugins, 'plugin_provider_snapshots', None) + if not callable(collector): + return {'providers': {}, 'errors': {'ospy': 'provider contract unavailable'}} + return collector() + + +def _relevant_provider_errors(snapshots): + relevant = {'water_meter'} + if plugin_options.get('pressure_enabled'): + relevant.add('pressure_monitor') + if plugin_options.get('tank_enabled'): + relevant.add(str(plugin_options.get('tank_provider_id', 'tank_monitor'))) + return { + key: value for key, value in snapshots.get('errors', {}).items() + if key in relevant + } + + +def _flow_reading(snapshots, now): + value, unit, status = _snapshot_value( + snapshots, 'water_meter', 'main', 'flow_lpm', now) + return (_safe_float(value, None) if status == 'ok' else None), unit or 'L/min', status + + +def _tank_reading(snapshots, now): + return _snapshot_value( + snapshots, + str(plugin_options.get('tank_provider_id', 'tank_monitor')), + str(plugin_options.get('tank_resource_id', 'main')), + str(plugin_options.get('tank_value_id', 'fill_percent')), + now, + ) + + +def _fault_label(code): + return { + 'flow_unavailable': _('Water flow measurement is unavailable'), + 'no_flow': _('No water flow was detected'), + 'low_flow': _('Water flow is below the expected range'), + 'high_flow': _('Water flow is above the expected range'), + 'unexpected_flow': _('Water is flowing while no irrigation station is active'), + 'pressure_unavailable': _('Pressure measurement is unavailable'), + 'pressure_missing': _('Pressure was not established'), + 'tank_unavailable': _('Tank level measurement is unavailable'), + 'tank_low': _('Tank level is below the safety limit'), + }.get(code, _('Irrigation safety fault')) + + +def _history_event_label(event): + return { + 'triggered': _('Incident triggered'), + 'cleared': _('Condition returned to normal'), + 'acknowledged': _('Incidents acknowledged'), + 'notification': _('Notification delivery'), + 'learning_started': _('Automatic learning started'), + 'learning_completed': _('Automatic learning completed'), + 'bypass_started': _('Temporary bypass started'), + 'bypass_cancelled': _('Temporary bypass cancelled'), + }.get(event, _('Irrigation Safety event')) + + +def _mode_label(mode): + return { + 'off': _('Off'), 'monitor': _('Monitor only'), + 'protect': _('Active protection'), + }.get(mode, _('Off')) + + +def _append_history(event, code='', detail='', action='', severity='info'): + with state_lock: + records = list(plugin_options.get('history', [])) + records.insert(0, { + 'id': uuid.uuid4().hex, + 'timestamp': int(time.time()), + 'datetime': datetime_string(), + 'event': event, + 'event_label': _history_event_label(event), + 'code': code, + 'label': _fault_label(code) if code else '', + 'detail': str(detail), + 'action': str(action), + 'severity': severity, + }) + plugin_options['history'] = records[:plugin_options['history_limit']] + + +def _stop_outputs(): + programs.run_now_program = None + run_once.clear() + log.finish_run(None) + stations.clear() + try: + from ospy.outputs import outputs + outputs.relay_output = False + except Exception: + log.error(NAME, _('Unable to switch off the master relay.') + '\n' + + traceback.format_exc()) + + +def _execute_safety_actions(): + actions = [] + if plugin_options.get('stop_all_outputs', True): + _stop_outputs() + actions.append(_('All outputs stopped')) + if plugin_options.get('disable_scheduler', True): + options.scheduler_enabled = False + actions.append(_('Scheduler disabled')) + with health_lock: + health_state['safety_actions'] += 1 + return '; '.join(actions) if actions else _('No control action configured') + + +class NotificationWorker(Thread): + def __init__(self): + Thread.__init__(self) + self.daemon = True + self._stop_event = runtime.stop_event + self._queue = queue.Queue(maxsize=64) + self.start() + runtime.register_thread(self) + + def submit(self, event, incident, test=False): + try: + self._queue.put_nowait((event, dict(incident), bool(test))) + return True + except queue.Full: + with health_lock: + health_state['notification_errors'] += 1 + return False + + def stop(self): + self._stop_event.set() + + def run(self): + while not self._stop_event.is_set(): + try: + item = self._queue.get(timeout=1) + except queue.Empty: + continue + try: + _deliver_notifications(*item) + except Exception: + with health_lock: + health_state['notification_errors'] += 1 + log.error(NAME, _('Irrigation Safety notification failed') + ':\n' + + traceback.format_exc()) + + +def _notification_message(event, incident): + if event == 'test': + return _('This is a test notification from Irrigation Safety.') + if event == 'cleared': + return _('Irrigation safety condition returned to normal: {}.').format( + incident.get('label', _fault_label(incident.get('code', '')))) + message = _('Irrigation safety incident: {}.').format( + incident.get('label', _fault_label(incident.get('code', '')))) + if incident.get('detail'): + message += ' ' + str(incident['detail']) + if incident.get('action'): + message += ' ' + _('Action: {}.').format(incident['action']) + return message + + +def _deliver_notifications(event, incident, test=False): + message = _notification_message('test' if test else event, incident) + title = _('Irrigation Safety') + results = [] + if plugin_options.get('send_email'): + result = {'channel': 'email', 'status': 'unavailable'} + for module in ('email_notifications_ssl', 'email_notifications'): + if module not in running(): + continue + method = getattr(get(module), 'try_mail', None) + if callable(method): + sent = method(message, message, attachment=None, subject=title) + result = {'channel': 'email', + 'status': 'sent' if sent is not False else 'error', + 'provider': module} + break + results.append(result) + if plugin_options.get('send_push'): + try: + from api.v1.push import push_dispatcher + code = 'irrigation_safety_{}'.format('test' if test else event) + queued = push_dispatcher.enqueue_notification({ + 'id': 'irrigation-safety-{}-{}'.format( + incident.get('id', 'test'), int(time.time() * 1000)), + 'event_type': 'automation', + 'severity': 'info' if event in ('cleared', 'test') else 'critical', + 'code': code, 'title': title, 'message': message, + 'data': { + 'source': 'irrigation_safety', + 'event': event, + 'incident_id': incident.get('id', ''), + 'fault_code': incident.get('code', ''), + }, + }) + results.append({'channel': 'push', + 'status': 'queued' if queued else 'unavailable'}) + except Exception as error: + results.append({'channel': 'push', 'status': 'error', + 'error': type(error).__name__}) + if any(item.get('status') == 'error' for item in results): + with health_lock: + health_state['notification_errors'] += 1 + _append_history('notification', incident.get('code', ''), + json.dumps(results, ensure_ascii=False), '', 'info') + + +notification_worker = None + + +class SafetyWorker(Thread): + def __init__(self): + Thread.__init__(self) + self.daemon = True + self._stop_event = runtime.stop_event + self.candidate_since = {} + self.active_signature = () + self.active_since = 0 + self.learning_samples = {} + self.current = self._empty_status() + self.footer = None + self._last_error_log = 0 + self.start() + runtime.register_thread(self) + + @staticmethod + def _empty_status(): + return { + 'updated': 0, 'mode': _mode(), 'flow_lpm': None, + 'flow_status': 'unavailable', 'active_stations': [], + 'expected_minimum': None, 'expected_maximum': None, + 'pressure': None, 'pressure_status': 'disabled', + 'tank': None, 'tank_unit': '', 'tank_status': 'disabled', + 'bypass': False, 'learning': {}, 'provider_errors': {}, + } + + def stop(self): + self._stop_event.set() + + def update(self): + self._stop_event.wait(0.01) + + def _log_problem(self, message): + now = time.time() + with health_lock: + health_state['last_error'] = now + health_state['last_error_message'] = str(message).splitlines()[-1] + if now - self._last_error_log >= ERROR_LOG_THROTTLE: + log.error(NAME, message) + self._last_error_log = now + + def _sync_footer(self): + if plugin_options.get('use_footer'): + if self.footer is None: + self.footer = showInFooter( + label=_('Irrigation Safety'), val='---', + button='irrigation_safety/settings') + incidents = _incidents() + locked = sum(1 for item in incidents.values() if item.get('latched')) + if locked: + self.footer.val = '{}: {}'.format(_('Locked incidents'), locked) + elif self.current.get('flow_lpm') is not None: + self.footer.val = '{}: {:.2f} L/min'.format( + _mode_label(_mode()), self.current['flow_lpm']) + else: + self.footer.val = _mode_label(_mode()) + elif self.footer is not None: + clear_plugin_runtime_data('irrigation_safety') + self.footer = None + + def _update_active_timing(self, active, now): + signature = tuple(sorted(active)) + if signature != self.active_signature: + self.active_signature = signature + self.active_since = now if signature else 0 + self.candidate_since = { + key: value for key, value in self.candidate_since.items() + if key.startswith('unexpected_flow:') + } + + def _candidate(self, result, code, scope, detail, confirm_seconds): + key = '{}:{}'.format(code, scope) + result[key] = { + 'key': key, 'code': code, 'scope': scope, + 'label': _fault_label(code), 'detail': detail, + 'confirm_seconds': int(confirm_seconds), + } + + def _evaluate(self, snapshots, active, profiles, now): + candidates = {} + flow, flow_unit, flow_status = _flow_reading(snapshots, now) + expected = model.expected_range(active, profiles) + active_names = [stations.get(value).name for value in active] + active_age = now - self.active_since if self.active_since else 0 + scope = ','.join(str(value) for value in sorted(active)) or 'none' + learning_ids = { + int(profile['station_id']) for profile in profiles + if profile.get('learning') + } + + if active and expected and active_age >= expected['startup_delay']: + detail_range = _('Flow: {} L/min; expected: {} to {} L/min; stations: {}.').format( + '---' if flow is None else round(flow, 3), + round(expected['minimum'], 3), round(expected['maximum'], 3), + ', '.join(active_names)) + if flow_status != 'ok': + self._candidate(candidates, 'flow_unavailable', scope, + detail_range, expected['confirm_seconds']) + else: + fault = model.flow_fault(flow, expected) + if fault and not (learning_ids.intersection(active) + and fault in ('low_flow', 'high_flow')): + self._candidate(candidates, fault, scope, detail_range, + expected['confirm_seconds']) + elif (not active and plugin_options.get('unexpected_flow_enabled') and + flow_status == 'ok' and flow is not None and + flow >= plugin_options['unexpected_flow_lpm']): + self._candidate( + candidates, 'unexpected_flow', 'none', + _('Flow: {} L/min; limit: {} L/min.').format( + round(flow, 3), plugin_options['unexpected_flow_lpm']), + plugin_options['unexpected_flow_confirm']) + + pressure, pressure_status = None, 'disabled' + if plugin_options.get('pressure_enabled') and active: + master, master_status = _master_active(snapshots, now) + pressure, _unit, pressure_status = _snapshot_value( + snapshots, 'pressure_monitor', 'main', 'pressure_present', now) + if active_age >= (expected['startup_delay'] if expected else 0): + if pressure_status != 'ok' or master_status not in ('ok', 'unavailable'): + self._candidate(candidates, 'pressure_unavailable', scope, + _('Pressure data is not current.'), + plugin_options['pressure_confirm']) + elif master and not bool(pressure): + self._candidate(candidates, 'pressure_missing', scope, + _('The master output is active but pressure is absent.'), + plugin_options['pressure_confirm']) + + tank, tank_unit, tank_status = None, '', 'disabled' + if (plugin_options.get('tank_enabled') and active and + active_age >= (expected['startup_delay'] if expected else 0)): + tank, tank_unit, tank_status = _tank_reading(snapshots, now) + if tank_status != 'ok': + self._candidate(candidates, 'tank_unavailable', scope, + _('Tank data is not current.'), + plugin_options['tank_confirm']) + elif _safe_float(tank, 0.0) < plugin_options['tank_minimum']: + self._candidate( + candidates, 'tank_low', scope, + _('Tank value: {} {}; safety limit: {} {}.').format( + round(_safe_float(tank), 3), tank_unit, + plugin_options['tank_minimum'], tank_unit), + plugin_options['tank_confirm']) + + self.current = { + 'updated': int(now), 'mode': _mode(), 'flow_lpm': flow, + 'flow_status': flow_status, 'active_stations': active_names, + 'active_station_ids': active, + 'expected_minimum': expected['minimum'] if expected else None, + 'expected_maximum': expected['maximum'] if expected else None, + 'pressure': pressure, 'pressure_status': pressure_status, + 'tank': tank, 'tank_unit': tank_unit, 'tank_status': tank_status, + 'bypass': _bypass_active(now), + 'bypass_until': plugin_options.get('bypass_until', 0), + 'learning': self._learning_status(profiles), + 'provider_errors': _relevant_provider_errors(snapshots), + } + return candidates, flow, flow_status + + def _learning_status(self, profiles): + return { + str(profile['station_id']): { + 'active': bool(profile.get('learning')), + 'samples': len(self.learning_samples.get( + int(profile['station_id']), [])), + 'required': profile['learning_samples'], + } + for profile in profiles + } + + def _learn(self, active, profiles, flow, flow_status, now): + if len(active) != 1 or flow_status != 'ok' or flow is None or flow <= 0: + return + station_id = int(active[0]) + profile = next((item for item in profiles + if int(item['station_id']) == station_id), None) + if not profile or not profile.get('learning'): + return + if now - self.active_since < profile['startup_delay']: + return + samples = self.learning_samples.setdefault(station_id, []) + samples.append(float(flow)) + if len(samples) < profile['learning_samples']: + return + learned = model.learned_range( + samples, profile['learning_tolerance_percent'], + profile['learning_minimum_margin_lpm']) + profile = dict(profile) + profile['minimum_flow_lpm'] = learned['minimum'] + profile['maximum_flow_lpm'] = learned['maximum'] + profile['learning'] = False + profile['enabled'] = True + _save_profile(profile) + self.learning_samples.pop(station_id, None) + detail = _('Station {} learned a median flow of {} L/min and a safe range of {} to {} L/min from {} samples.').format( + profile['name'], learned['median'], learned['minimum'], + learned['maximum'], learned['samples']) + _append_history('learning_completed', '', detail) + log.info(NAME, detail) + + def _reconcile_incidents(self, candidates, now): + incidents = _incidents() + changed = False + for key, candidate in candidates.items(): + since = self.candidate_since.setdefault(key, now) + if not model.confirmed(since, now, candidate['confirm_seconds']): + continue + incident = incidents.get(key) + if incident and incident.get('condition_active'): + continue + action = '' + bypass = _bypass_active(now) + if _mode() == 'protect' and not bypass: + action = _execute_safety_actions() + elif _mode() == 'protect' and bypass: + action = _('Temporary bypass is active; control action was not executed') + else: + action = _('Monitor-only mode; control action was not executed') + incident = { + 'id': uuid.uuid4().hex, 'key': key, + 'code': candidate['code'], 'scope': candidate['scope'], + 'label': candidate['label'], 'detail': candidate['detail'], + 'opened_at': int(now), 'condition_active': True, + 'latched': bool(plugin_options.get('latch_incidents', True)), + 'action': action, + } + incidents[key] = incident + _append_history('triggered', candidate['code'], + candidate['detail'], action, 'critical') + log.error(NAME, '{}: {}'.format(candidate['label'], candidate['detail'])) + if notification_worker is not None: + notification_worker.submit('triggered', incident) + changed = True + + for key, incident in list(incidents.items()): + if not incident.get('condition_active') or key in candidates: + continue + incident = dict(incident) + incident['condition_active'] = False + incident['cleared_at'] = int(now) + if not incident.get('latched'): + incidents.pop(key, None) + else: + incidents[key] = incident + _append_history('cleared', incident.get('code', ''), + incident.get('detail', ''), '', 'info') + log.info(NAME, '{}: {}'.format( + _('Irrigation safety condition returned to normal'), + incident.get('label', ''))) + if plugin_options.get('notify_recovery') and notification_worker is not None: + notification_worker.submit('cleared', incident) + changed = True + + self.candidate_since = { + key: since for key, since in self.candidate_since.items() + if key in candidates + } + if changed: + with state_lock: + plugin_options['incidents'] = incidents + + def _enforce_lock(self): + if _mode() != 'protect' or _bypass_active() or not any( + item.get('latched') for item in _incidents().values()): + return + if _active_station_ids() or any(stations.active(value) for value in ( + getattr(stations, 'master', None), + getattr(stations, 'master_two', None)) if isinstance(value, int)): + _stop_outputs() + + def run(self): + while not self._stop_event.is_set(): + try: + _normalize_settings() + mode = _mode() + if mode == 'off': + self.current = self._empty_status() + self.current['mode'] = mode + self._sync_footer() + self._stop_event.wait(plugin_options['check_interval']) + continue + now = time.time() + snapshots = _provider_snapshots() + active = _active_station_ids() + profiles = _profiles() + self._update_active_timing(active, now) + candidates, flow, flow_status = self._evaluate( + snapshots, active, profiles, now) + self._learn(active, profiles, flow, flow_status, now) + self._reconcile_incidents(candidates, now) + self._enforce_lock() + self._sync_footer() + with health_lock: + health_state['last_cycle'] = now + health_state['provider_errors'] = len( + _relevant_provider_errors(snapshots)) + health_state['last_error_message'] = '' + self._stop_event.wait(plugin_options['check_interval']) + except Exception: + self._log_problem(_('Irrigation Safety evaluation failed') + ':\n' + + traceback.format_exc()) + self._stop_event.wait(5) + + +safety_worker = None + + +def _incidents(): + with state_lock: + value = plugin_options.get('incidents', {}) + return dict(value) if isinstance(value, dict) else {} + + +def _bypass_active(now=None): + now = time.time() if now is None else now + return _safe_int(plugin_options.get('bypass_until'), 0) > now + + +def start(): + global safety_worker, notification_worker + _normalize_settings() + if notification_worker is None: + notification_worker = NotificationWorker() + if safety_worker is None: + safety_worker = SafetyWorker() + + +def stop(): + global safety_worker, notification_worker + for worker in (safety_worker, notification_worker): + if worker is not None: + worker.stop() + for worker in (safety_worker, notification_worker): + if worker is not None: + worker.join(15) + safety_worker = None + notification_worker = None + clear_plugin_runtime_data('irrigation_safety') + + +def acknowledge_incidents(): + incidents = _incidents() + active = [item for item in incidents.values() if item.get('condition_active')] + if active: + raise ValueError(_('An incident cannot be acknowledged while its fault condition is active.')) + count = len(incidents) + with state_lock: + plugin_options['incidents'] = {} + _append_history('acknowledged', '', + _('Administrator acknowledged {} incident(s).').format(count)) + return count + + +def _tank_sources(): + sources = [] + try: + snapshots = _provider_snapshots().get('providers', {}) + for provider_id in ('tank_monitor', 'current_loop_tanks_monitor'): + provider = snapshots.get(provider_id, {}) + for resource in provider.get('resources', []): + for value in resource.get('values', []): + if value.get('id') not in ('fill_percent', 'level_cm'): + continue + sources.append({ + 'provider_id': provider_id, + 'resource_id': str(resource.get('id', '')), + 'value_id': str(value.get('id', '')), + 'label': '{} / {} / {} ({})'.format( + provider_id, resource.get('name') or resource.get('id'), + value.get('id'), value.get('unit', '')), + }) + except Exception: + pass + selected = { + 'provider_id': str(plugin_options.get('tank_provider_id', 'tank_monitor')), + 'resource_id': str(plugin_options.get('tank_resource_id', 'main')), + 'value_id': str(plugin_options.get('tank_value_id', 'fill_percent')), + } + if not any(all(item.get(key) == value for key, value in selected.items()) + for item in sources): + selected['label'] = '{} / {} / {}'.format( + selected['provider_id'], selected['resource_id'], selected['value_id']) + sources.append(selected) + return sources + + +def _web_data(): + return { + 'modes': [('off', _('Off')), ('monitor', _('Monitor only')), + ('protect', _('Active protection'))], + 'profiles': _profiles(), + 'tank_sources': _tank_sources(), + 'incidents': list(_incidents().values()), + 'history': list(plugin_options.get('history', []))[:100], + } + + +def _public_status(): + current = dict(safety_worker.current) if safety_worker is not None else SafetyWorker._empty_status() + incidents = list(_incidents().values()) + current.update({ + 'mode_label': _mode_label(_mode()), + 'incidents': incidents, + 'locked_incidents': sum(1 for item in incidents if item.get('latched')), + 'active_incidents': sum(1 for item in incidents if item.get('condition_active')), + 'history': list(plugin_options.get('history', []))[:100], + 'worker_running': safety_worker is not None and safety_worker.is_alive(), + }) + return current + + +def health(): + with health_lock: + state = dict(health_state) + worker_running = safety_worker is not None and safety_worker.is_alive() + details = { + _('Worker thread'): _('Running') if worker_running else _('Stopped'), + _('Safety mode'): _mode_label(_mode()), + _('Active incidents'): len([item for item in _incidents().values() + if item.get('condition_active')]), + _('Locked incidents'): len([item for item in _incidents().values() + if item.get('latched')]), + _('Safety actions'): state['safety_actions'], + _('Provider errors'): state['provider_errors'], + _('Notification errors'): state['notification_errors'], + _('Last successful cycle'): ( + datetime_string(time.localtime(state['last_cycle'])) + if state['last_cycle'] else _('Not available')), + } + if state['last_error_message']: + details[_('Last error')] = state['last_error_message'] + if not worker_running: + return {'status': 'error', + 'summary': _('Irrigation Safety worker is stopped.'), + 'details': details} + if _mode() == 'off': + return {'status': 'unknown', 'summary': _('Irrigation Safety is off.'), + 'details': details} + if any(item.get('condition_active') for item in _incidents().values()): + return {'status': 'error', + 'summary': _('An irrigation safety incident is active.'), + 'details': details} + if state['last_error_message'] or state['provider_errors']: + return {'status': 'warning', + 'summary': _('Irrigation Safety has unavailable data.'), + 'details': details} + return {'status': 'ok', 'summary': _('Irrigation Safety is monitoring.'), + 'details': details} + + +def mobile_status(): + result = health() + return {'status': result['status'], 'title': _('Irrigation Safety'), + 'summary': result['summary'], 'updated': datetime_string()} + + +def mobile_cards(**_kwargs): + status = _public_status() + flow = status.get('flow_lpm') + return [{ + 'id': 'irrigation_safety', 'kind': 'metrics', + 'title': _('Irrigation Safety'), + 'status': health().get('status', 'unknown'), + 'summary': health().get('summary', ''), + 'metrics': [ + {'id': 'mode', 'label': _('Safety mode'), + 'value': status['mode_label'], 'unit': ''}, + {'id': 'flow', 'label': _('Current flow'), + 'value': '---' if flow is None else round(flow, 3), 'unit': 'L/min'}, + {'id': 'active_stations', 'label': _('Active stations'), + 'value': ', '.join(status.get('active_stations', [])) or _('None'), + 'unit': ''}, + {'id': 'active_incidents', 'label': _('Active incidents'), + 'value': status['active_incidents'], 'unit': ''}, + {'id': 'locked_incidents', 'label': _('Locked incidents'), + 'value': status['locked_incidents'], 'unit': ''}, + ], + }] + + +def provider_capabilities(): + return { + 'contract': 'ospy.provider.v1', + 'provider_id': 'irrigation_safety', + 'resource_types': ['irrigation_safety'], + 'values': [ + {'id': 'protection_active', 'quantity': 'state', 'unit': '', + 'value_type': 'boolean'}, + {'id': 'monitoring_active', 'quantity': 'state', 'unit': '', + 'value_type': 'boolean'}, + {'id': 'active_incidents', 'quantity': 'count', 'unit': '', + 'value_type': 'integer'}, + {'id': 'locked_incidents', 'quantity': 'count', 'unit': '', + 'value_type': 'integer'}, + ], + 'events': [{'code': 'irrigation_safety.incident'}], + 'alerts': [{'code': 'irrigation_safety.active_incident'}], + 'actions': [], + } + + +def provider_snapshot(): + status = _public_status() + observed = datetime.datetime.utcfromtimestamp( + status.get('updated') or time.time()).isoformat() + 'Z' + values = [ + ('protection_active', _mode() == 'protect', 'boolean'), + ('monitoring_active', _mode() in ('monitor', 'protect'), 'boolean'), + ('active_incidents', status['active_incidents'], 'integer'), + ('locked_incidents', status['locked_incidents'], 'integer'), + ] + alerts = [{ + 'id': 'irrigation-safety.active-incident', + 'code': 'irrigation_safety.active_incident', + 'severity': 'critical', 'state': 'active', 'opened_at': observed, + }] if status['active_incidents'] else [] + return { + 'contract': 'ospy.provider.v1', + 'provider_id': 'irrigation_safety', + 'status': 'ok' if status['worker_running'] else 'unavailable', + 'observed_at': observed, + 'resources': [{ + 'id': 'main', 'type': 'irrigation_safety', + 'status': 'ok' if status['worker_running'] else 'unavailable', + 'values': [{ + 'id': value_id, 'quantity': 'state' if value_type == 'boolean' else 'count', + 'value': value, 'unit': '', 'value_type': value_type, + 'quality': 'derived', 'observed_at': observed, + } for value_id, value, value_type in values], + 'alerts': alerts, + }], + 'events': [], 'alerts': alerts, + } + + +class settings_page(ProtectedPage): + def GET(self): + _normalize_settings() + return self.plugin_render.irrigation_safety( + plugin_options, _web_data(), '') + + def POST(self): + from ospy import server + + qdict = web.input() + verify_csrf(qdict) + error = '' + try: + if server.session.get('category') != 'admin': + raise ValueError(_('Only an administrator can change irrigation safety settings.')) + action = str(qdict.get('action', '')) + if action == 'save_settings': + mode = str(qdict.get('mode', 'off')) + if mode not in model.MODES: + raise ValueError(_('Select a valid safety mode.')) + plugin_options['mode'] = mode + for key in ( + 'use_footer', 'send_email', 'send_push', 'notify_recovery', + 'stop_all_outputs', 'disable_scheduler', 'latch_incidents', + 'unexpected_flow_enabled', 'pressure_enabled', 'tank_enabled'): + plugin_options[key] = qdict.get(key) == 'on' + for key in ('check_interval', 'data_timeout', 'history_limit', + 'unexpected_flow_confirm', 'pressure_confirm', + 'tank_confirm'): + plugin_options[key] = _safe_int(qdict.get(key), plugin_options[key]) + for key in ('unexpected_flow_lpm', 'tank_minimum'): + plugin_options[key] = _safe_float(qdict.get(key), plugin_options[key]) + tank_source = str(qdict.get('tank_source', '')) + parts = tank_source.split('|', 2) + if len(parts) == 3: + plugin_options['tank_provider_id'] = parts[0] + plugin_options['tank_resource_id'] = parts[1] + plugin_options['tank_value_id'] = parts[2] + _normalize_settings() + elif action == 'save_profile': + station_id = _safe_int(qdict.get('station_id'), -1) + profile = _profile(station_id) + if profile is None: + raise ValueError(_('Selected station does not exist.')) + _save_profile(_profile_from_input(qdict, profile)) + elif action == 'start_learning': + station_id = _safe_int(qdict.get('station_id'), -1) + profile = _profile(station_id) + if profile is None: + raise ValueError(_('Selected station does not exist.')) + profile = _profile_from_input(qdict, profile) + profile['learning'] = True + profile['enabled'] = True + _save_profile(profile) + if safety_worker is not None: + safety_worker.learning_samples[station_id] = [] + _append_history('learning_started', '', + _('Automatic flow learning started for station {}.').format(profile['name'])) + elif action == 'stop_learning': + station_id = _safe_int(qdict.get('station_id'), -1) + profile = _profile(station_id) + if profile is not None: + profile = dict(profile) + profile['learning'] = False + _save_profile(profile) + if safety_worker is not None: + safety_worker.learning_samples.pop(station_id, None) + elif action == 'acknowledge': + acknowledge_incidents() + elif action == 'start_bypass': + minutes = model.clamp(_safe_int(qdict.get('bypass_minutes'), 15), 1, 1440) + plugin_options['bypass_until'] = int(time.time()) + minutes * 60 + _append_history('bypass_started', '', + _('Temporary bypass started for {} minutes.').format(minutes)) + elif action == 'cancel_bypass': + plugin_options['bypass_until'] = 0 + _append_history('bypass_cancelled', '', _('Temporary bypass was cancelled.')) + elif action == 'test_notifications': + if notification_worker is not None: + notification_worker.submit('test', {'id': 'test', 'code': ''}, True) + elif action == 'clear_history': + plugin_options['history'] = [] + except (TypeError, ValueError) as exception: + error = str(exception) + if not error: + raise web.seeother(plugin_url(settings_page), True) + return self.plugin_render.irrigation_safety( + plugin_options, _web_data(), error) + + +class status_json(ProtectedPage): + def GET(self): + web.header('Content-Type', 'application/json; charset=utf-8') + return json.dumps(_public_status(), ensure_ascii=False, allow_nan=False) + + +class help_page(ProtectedPage): + def GET(self): + return self.plugin_render.irrigation_safety_help() diff --git a/plugins/irrigation_safety/model.py b/plugins/irrigation_safety/model.py new file mode 100644 index 00000000..a7182f61 --- /dev/null +++ b/plugins/irrigation_safety/model.py @@ -0,0 +1,163 @@ +# -*- coding: utf-8 -*- +"""Pure configuration, evaluation and learning helpers for Irrigation Safety.""" + +import math +import statistics + + +MODES = ('off', 'monitor', 'protect') +MAX_HISTORY = 5000 +MIN_CHECK_INTERVAL = 1 +MAX_CHECK_INTERVAL = 60 +MIN_LEARNING_SAMPLES = 10 +MAX_LEARNING_SAMPLES = 600 + + +def safe_int(value, default=0): + try: + return int(value) + except (TypeError, ValueError, OverflowError): + return default + + +def safe_float(value, default=0.0): + try: + result = float(str(value).replace(',', '.')) + return result if math.isfinite(result) else default + except (TypeError, ValueError, OverflowError): + return default + + +def clamp(value, minimum, maximum): + return max(minimum, min(maximum, value)) + + +def default_profile(station_id, station_name): + return { + 'station_id': int(station_id), + 'name': str(station_name), + 'enabled': False, + 'minimum_flow_lpm': 0.1, + 'maximum_flow_lpm': 100.0, + 'startup_delay': 15, + 'confirm_seconds': 10, + 'learning': False, + 'learning_samples': 30, + 'learning_tolerance_percent': 20.0, + 'learning_minimum_margin_lpm': 0.5, + } + + +def normalize_profile(value, station_id, station_name): + profile = default_profile(station_id, station_name) + if isinstance(value, dict): + profile.update(value) + profile['station_id'] = int(station_id) + profile['name'] = str(station_name) + profile['enabled'] = bool(profile.get('enabled', False)) + profile['minimum_flow_lpm'] = clamp( + safe_float(profile.get('minimum_flow_lpm'), 0.1), 0.0, 1000000.0) + profile['maximum_flow_lpm'] = clamp( + safe_float(profile.get('maximum_flow_lpm'), 100.0), 0.0, 1000000.0) + profile['startup_delay'] = clamp( + safe_int(profile.get('startup_delay'), 15), 0, 3600) + profile['confirm_seconds'] = clamp( + safe_int(profile.get('confirm_seconds'), 10), 1, 3600) + profile['learning'] = bool(profile.get('learning', False)) + profile['learning_samples'] = clamp( + safe_int(profile.get('learning_samples'), 30), + MIN_LEARNING_SAMPLES, MAX_LEARNING_SAMPLES) + profile['learning_tolerance_percent'] = clamp( + safe_float(profile.get('learning_tolerance_percent'), 20.0), 1.0, 100.0) + profile['learning_minimum_margin_lpm'] = clamp( + safe_float(profile.get('learning_minimum_margin_lpm'), 0.5), + 0.0, 1000000.0) + return profile + + +def normalize_profiles(values, stations): + """Return one profile for every enabled non-master OSPy station.""" + stored = {} + for value in values if isinstance(values, list) else []: + if isinstance(value, dict): + stored[safe_int(value.get('station_id'), -1)] = value + return [ + normalize_profile(stored.get(int(station_id)), station_id, station_name) + for station_id, station_name in stations + ] + + +def validate_profile(profile): + if profile['minimum_flow_lpm'] >= profile['maximum_flow_lpm']: + raise ValueError('minimum flow must be lower than maximum flow') + + +def expected_range(active_station_ids, profiles): + """Combine configured limits when every active station has a safety profile.""" + active_ids = set(int(value) for value in active_station_ids) + selected = [ + profile for profile in profiles + if profile.get('enabled') and int(profile['station_id']) in active_ids + ] + if not active_ids or len(selected) != len(active_ids): + return None + return { + 'minimum': sum(profile['minimum_flow_lpm'] for profile in selected), + 'maximum': sum(profile['maximum_flow_lpm'] for profile in selected), + 'confirm_seconds': max(profile['confirm_seconds'] for profile in selected), + 'startup_delay': max(profile['startup_delay'] for profile in selected), + } + + +def flow_fault(flow_lpm, expected): + if expected is None: + return None + value = safe_float(flow_lpm, None) + if value is None: + return 'flow_unavailable' + if value <= 0.0: + return 'no_flow' + if value < expected['minimum']: + return 'low_flow' + if value > expected['maximum']: + return 'high_flow' + return None + + +def percentile(values, fraction): + ordered = sorted(float(value) for value in values) + if not ordered: + raise ValueError('no values') + position = (len(ordered) - 1) * fraction + lower = int(math.floor(position)) + upper = int(math.ceil(position)) + if lower == upper: + return ordered[lower] + return ordered[lower] + (ordered[upper] - ordered[lower]) * (position - lower) + + +def learned_range(samples, tolerance_percent=20.0, minimum_margin_lpm=0.5): + """Build a robust range from positive finite samples using P10/P90.""" + values = [safe_float(value, -1.0) for value in samples] + values = [value for value in values if value > 0.0 and math.isfinite(value)] + if len(values) < MIN_LEARNING_SAMPLES: + raise ValueError('not enough learning samples') + center = statistics.median(values) + low = percentile(values, 0.10) + high = percentile(values, 0.90) + margin = max( + safe_float(minimum_margin_lpm, 0.5), + center * safe_float(tolerance_percent, 20.0) / 100.0, + ) + minimum = max(0.01, low - margin) + maximum = max(minimum + 0.01, high + margin) + return { + 'minimum': round(minimum, 3), + 'maximum': round(maximum, 3), + 'median': round(center, 3), + 'samples': len(values), + } + + +def confirmed(active_since, now, confirm_seconds): + return active_since is not None and now - active_since >= confirm_seconds diff --git a/plugins/irrigation_safety/plugin.json b/plugins/irrigation_safety/plugin.json new file mode 100644 index 00000000..f5edb512 --- /dev/null +++ b/plugins/irrigation_safety/plugin.json @@ -0,0 +1,22 @@ +{ + "schema_version": 1, + "id": "irrigation_safety", + "name": "Irrigation Safety", + "version": "1.0.0", + "description": "Monitors irrigation flow, pressure and tank level, learns station flow ranges and can stop outputs and disable the scheduler when a confirmed fault occurs.", + "author": "Martin Pihrt", + "license": "GPL-3.0", + "ospy": {"min": "3.0.354"}, + "python": {"min_version": "3.8"}, + "dependencies": [ + {"id": "water_meter", "required": true}, + {"id": "pressure_monitor", "required": false}, + {"id": "tank_monitor", "required": false}, + {"id": "current_loop_tanks_monitor", "required": false}, + {"id": "email_notifications", "required": false}, + {"id": "email_notifications_ssl", "required": false} + ], + "permissions": ["email", "system"], + "provider": {"contract": "ospy.provider.v1"}, + "mobile": {"api_version": 1, "actions": []} +} diff --git a/plugins/irrigation_safety/static/irrigation_safety.css b/plugins/irrigation_safety/static/irrigation_safety.css new file mode 100644 index 00000000..5c540c8a --- /dev/null +++ b/plugins/irrigation_safety/static/irrigation_safety.css @@ -0,0 +1,80 @@ +.safetyDashboard { + display: grid; + gap: 10px; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + margin: 12px 0; +} + +.safetyMetric { + background: rgba(127, 127, 127, .08); + border: 1px solid #999; + border-radius: 6px; + display: flex; + flex-direction: column; + min-height: 68px; + padding: 10px; +} + +.safetyMetric span { font-size: .88em; opacity: .8; } +.safetyMetric strong { font-size: 1.15em; margin-top: 8px; } + +.safetyCard { + border: 1px solid #999; + border-radius: 6px; + margin: 10px 0; + padding: 0 10px 10px; +} + +.safetyCard summary { + cursor: pointer; + display: flex; + font-weight: bold; + justify-content: space-between; + padding: 10px 0; +} + +.safetyCardTitle { align-items: center; display: flex; flex-wrap: wrap; gap: 8px; } +.safetyCardState { font-weight: bold; } +.safetyEnabled { color: #278636; } +.safetyDisabled { color: #b21f2d; } + +.safetyCardButton { + background: #e0a800; + border: 1px solid #b88900; + border-radius: 12px; + color: #fff; + font-size: .85em; + padding: 3px 12px; + text-transform: uppercase; +} + +.safetyError { background: #b21f2d; color: #fff; margin: 10px 0; padding: 8px; } +.safetyHidden { display: none; } +.safetyActions, .safetyControlGrid { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; } +.safetyControlGrid { align-items: center; } +.safetyControlGrid form { align-items: center; display: flex; gap: 6px; margin: 0; } +.safetyOptions input[type="number"], .safetyOptions select { max-width: 220px; } + +.safetySwitch { cursor: pointer; display: inline-block; height: 24px; position: relative; width: 50px; } +.safetySwitch input { height: 1px; opacity: 0; position: absolute; width: 1px; } +.safetySlider { background-color: #ff4d4d; border-radius: 34px; bottom: 0; left: 0; position: absolute; right: 0; top: 0; transition: .25s; } +.safetySlider:before { background: #fff; border-radius: 50%; bottom: 3px; content: ""; height: 18px; left: 4px; position: absolute; transition: .25s; width: 18px; } +.safetySwitch input:checked + .safetySlider { background-color: #4caf50; } +.safetySwitch input:checked + .safetySlider:before { transform: translateX(24px); } +.safetySwitch input:focus-visible + .safetySlider { outline: 2px solid #2276d2; outline-offset: 2px; } + +.safetyTableWrap { overflow-x: auto; width: 100%; } +.safetyTable { border-collapse: collapse; width: 100%; } +.safetyTable th, .safetyTable td { border-bottom: 1px solid #aaa; padding: 7px; text-align: left; vertical-align: top; } +.safetyTable th { font-weight: bold; } +.safetyStateActive, .safetyStateLocked { color: #b21f2d; font-weight: bold; } +.safetyStateCleared { color: #278636; font-weight: bold; } +.safetyClear { margin-bottom: 8px; text-align: right; } + +@media (max-width: 700px) { + .safetyOptions, .safetyOptions tbody, .safetyOptions tr, .safetyOptions td { display: block; width: 100%; } + .safetyOptions td { box-sizing: border-box; padding: 5px 0; } + .safetyOptions input[type="number"], .safetyOptions select { box-sizing: border-box; max-width: 100%; width: 100%; } + .safetyControlGrid { align-items: stretch; flex-direction: column; } + .safetyControlGrid form { align-items: stretch; flex-wrap: wrap; } +} diff --git a/plugins/irrigation_safety/static/irrigation_safety.js b/plugins/irrigation_safety/static/irrigation_safety.js new file mode 100644 index 00000000..8a43145d --- /dev/null +++ b/plugins/irrigation_safety/static/irrigation_safety.js @@ -0,0 +1,92 @@ +(function () { + "use strict"; + + function text(id, value) { + var element = document.getElementById(id); + if (element) element.textContent = value; + } + + function cell(row, value, className) { + var item = document.createElement("td"); + item.textContent = value == null ? "" : String(value); + if (className) item.className = className; + row.appendChild(item); + } + + function renderRows(targetId, values, renderer, emptyText, columns) { + var body = document.getElementById(targetId); + if (!body) return; + body.textContent = ""; + if (!values || !values.length) { + var emptyRow = document.createElement("tr"); + var emptyCell = document.createElement("td"); + emptyCell.colSpan = columns; + emptyCell.textContent = emptyText; + emptyRow.appendChild(emptyCell); + body.appendChild(emptyRow); + return; + } + values.forEach(function (value) { body.appendChild(renderer(value)); }); + } + + function render(status) { + var cfg = window.irrigationSafetyConfig; + text("safetyMode", status.mode_label || "---"); + text("safetyFlow", status.flow_lpm == null ? cfg.texts.unavailable : Number(status.flow_lpm).toFixed(2) + " L/min"); + text("safetyExpected", status.expected_minimum == null || status.expected_maximum == null ? cfg.texts.unavailable : Number(status.expected_minimum).toFixed(2) + "–" + Number(status.expected_maximum).toFixed(2) + " L/min"); + text("safetyStations", status.active_stations && status.active_stations.length ? status.active_stations.join(", ") : cfg.texts.none); + text("safetyIncidents", status.active_incidents + " / " + status.locked_incidents); + text("safetyBypass", status.bypass ? new Date(status.bypass_until * 1000).toLocaleString() : cfg.texts.none); + + Object.keys(status.learning || {}).forEach(function (stationId) { + var learning = status.learning[stationId]; + if (learning.active) text("learningStatus-" + stationId, learning.samples + " / " + learning.required); + }); + + renderRows("safetyIncidentRows", status.incidents, function (incident) { + var row = document.createElement("tr"); + cell(row, new Date(incident.opened_at * 1000).toLocaleString()); + cell(row, incident.label + (incident.detail ? " — " + incident.detail : "")); + var state = incident.condition_active ? cfg.texts.active : cfg.texts.cleared; + if (incident.latched) state += " / " + cfg.texts.locked; + cell(row, state, incident.condition_active ? "safetyStateActive" : (incident.latched ? "safetyStateLocked" : "safetyStateCleared")); + cell(row, incident.action || ""); + return row; + }, cfg.texts.none, 4); + + renderRows("safetyHistoryRows", status.history, function (record) { + var row = document.createElement("tr"); + cell(row, record.datetime || new Date(record.timestamp * 1000).toLocaleString()); + cell(row, record.label || record.event_label || ""); + cell(row, record.detail || ""); + cell(row, record.action || ""); + return row; + }, cfg.texts.none, 4); + } + + function poll() { + var cfg = window.irrigationSafetyConfig; + fetch(cfg.statusUrl, {credentials: "same-origin", cache: "no-store"}) + .then(function (response) { + if (!response.ok) throw new Error("HTTP " + response.status); + return response.json(); + }) + .then(function (status) { + var error = document.getElementById("safetyLiveError"); + if (error) error.classList.add("safetyHidden"); + render(status); + }) + .catch(function () { + var error = document.getElementById("safetyLiveError"); + if (error) { + error.textContent = cfg.texts.pollingError; + error.classList.remove("safetyHidden"); + } + }); + } + + document.addEventListener("DOMContentLoaded", function () { + poll(); + window.setInterval(poll, 2000); + }); +}()); diff --git a/plugins/irrigation_safety/templates/irrigation_safety.html b/plugins/irrigation_safety/templates/irrigation_safety.html new file mode 100644 index 00000000..559f1398 --- /dev/null +++ b/plugins/irrigation_safety/templates/irrigation_safety.html @@ -0,0 +1,234 @@ +$def with(plugin_options, data, error='') + +$var title: $_('Irrigation Safety') +$var page: plugins + + + + + +
+
$_('Irrigation Safety')
+ $_('Help')
+ + $if error: + + + +
+
+ $_('Safety mode')--- +
+
+ $_('Current flow')--- +
+
+ $_('Expected flow')--- +
+
+ $_('Active stations')--- +
+
+ $_('Active incidents')0 +
+
+ $_('Temporary bypass')$_('Inactive') +
+
+ +
+ $_('General protection settings')$_('Edit') +
+ $:csrf_input() + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
$_('Safety mode'): + + $_('Check interval'): $_('seconds')
$_('Measurement timeout'): $_('seconds')$_('History limit'):
$_('Stop all outputs'):$_('Disable scheduler'):
$_('Lock incidents until acknowledgement'):$_('Show in footer'):
$_('Send e-mail'):$_('Send mobile push notification'):
$_('Notify when condition returns to normal'):
+ +

$_('Flow without irrigation')

+ + + + + + + + + + + + +
$_('Detect flow without an active station'):$_('Flow limit'): L/min
$_('Confirmation time'): $_('seconds')
+ +

$_('Pressure protection')

+ + + + + + + +
$_('Monitor pressure'):$_('Confirmation time'): $_('seconds')
+ +

$_('Tank protection')

+ $ selected_tank = '%s|%s|%s' % (plugin_options['tank_provider_id'], plugin_options['tank_resource_id'], plugin_options['tank_value_id']) + + + + + + + + + + + + + +
$_('Monitor tank level'):$_('Tank source'): + +
$_('Minimum tank value'):$_('Confirmation time'): $_('seconds')
+
+
+
+ +

$_('Station flow profiles')

+

$_('Automatic learning collects flow only while exactly one selected station is active. It ignores startup delay and calculates a robust range from the required number of samples.')

+ $for profile in data['profiles']: +
+ + $profile['name']${_('Enabled') if profile['enabled'] else _('Disabled')} + $_('Edit') + +
+ $:csrf_input() + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
$_('Protect station'):$_('Station'):$profile['name']
$_('Minimum flow'): L/min$_('Maximum flow'): L/min
$_('Startup delay'): $_('seconds')$_('Fault confirmation'): $_('seconds')
$_('Learning samples'):$_('Learning tolerance'): %
$_('Minimum learning margin'): L/min$_('Learning status'):${_('Waiting for a single-station run') if profile['learning'] else _('Inactive')}
+
+ + $if profile['learning']: + + $else: + +
+
+
+ +

$_('Incident control')

+
+
+ $:csrf_input() + +
+
+ $:csrf_input() + $_('minutes') + +
+
+ $:csrf_input() + +
+
+ $:csrf_input() + +
+
+ +

$_('Incidents')

+
$_('Opened')$_('Incident')$_('State')$_('Action')
$_('No incidents')
+ +

$_('History')

+
+ $:csrf_input() +
+
$_('Date and time')$_('Event')$_('Detail')$_('Action')
$_('No history')
+
+ +
diff --git a/plugins/irrigation_safety/templates/irrigation_safety_help.html b/plugins/irrigation_safety/templates/irrigation_safety_help.html new file mode 100644 index 00000000..5ce44b4c --- /dev/null +++ b/plugins/irrigation_safety/templates/irrigation_safety_help.html @@ -0,0 +1,39 @@ +$def with() + +$var title: $_('Irrigation Safety help') +$var page: plugins + +
+
$_('Irrigation Safety help')
+ +

$_('Purpose')

+

$_('Irrigation Safety combines the current OSPy station state with cached Water Meter, Pressure Monitor and tank-monitor values. It never polls I2C or GPIO hardware itself.')

+

$_('Use Monitor only first. This records and reports incidents without changing outputs. After every station profile has been checked, Active protection can stop all outputs, disable the scheduler and keep the incident locked until an administrator acknowledges it.')

+ +

$_('Station flow profiles')

+

$_('Each enabled non-master OSPy station has its own expected minimum and maximum flow. When several configured stations run together, their limits are added. A station without an enabled profile prevents range enforcement for that combination instead of guessing a limit.')

+

$_('Startup delay begins whenever the active station combination changes. A flow fault must then remain present for the configured confirmation time before an incident is opened.')

+ +

$_('Automatic learning')

+

$_('Start learning for one station and run that station by itself. Samples are accepted only after startup delay, only from a current Water Meter reading and only while exactly that station is active.')

+

$_('After the requested number of samples, the plug-in calculates the median and robust tenth-to-ninetieth-percentile range. It expands that range by the configured percentage or minimum margin, whichever is larger, saves the new limits and stops learning automatically.')

+ +

$_('Additional protection')

+ + +

$_('Incidents and actions')

+

$_('A confirmed incident is written to bounded history before notifications are sent. In Active protection, output shutdown and scheduler disabling happen before e-mail or push delivery.')

+

$_('A locked incident continuously prevents irrigation outputs from remaining active. It can be acknowledged only after every associated fault condition has returned to normal. Acknowledgement never enables the scheduler again.')

+

$_('Temporary bypass suppresses control actions for a bounded time but keeps monitoring, history and notifications active. It expires automatically.')

+ +

$_('Notifications and live page')

+

$_('E-mail uses the running E-mail Notifications SSL or E-mail Notifications plug-in. Mobile push uses OSPy Mobile API push delivery and its Automation notification category. Delivery runs outside the safety evaluation worker.')

+

$_('The overview reloads current measurements, stations, learning progress, incidents and history in the background. The page does not need to be refreshed.')

+ +

$_('Back')

+
diff --git a/plugins/sms_adj/README.md b/plugins/sms_adj/README.md index 6f81d932..19f75fdb 100644 --- a/plugins/sms_adj/README.md +++ b/plugins/sms_adj/README.md @@ -10,8 +10,7 @@ are not included in diagnostics. Control your sprinkler using SMS (Short Message Service). For this plugin is needed to SMS modem with the telephone service provider. -This plugin needs Gammu. If not installed Gammu and Python-Gammu, plugin installs Gammu and Python-Gammu in to the system himself. -On first use (if run installation gammu) please wait for status. +This plugin needs Gammu. If not installed use: `sudo apt install -y gammu python3-gammu`. Reports shall be transmitted only on the phone number of administrator who sent the order (to other phone numbers plugin unresponsive and SMS messages deletes). Plugin setup diff --git a/plugins/system_update/README.md b/plugins/system_update/README.md index 358966fb..ef689296 100644 --- a/plugins/system_update/README.md +++ b/plugins/system_update/README.md @@ -9,7 +9,7 @@ The repository owner must create and maintain the remote `beta` branch. If it do Before installing an update, the plug-in creates a verified OSPy system safety backup. If that backup cannot be created, the update is not started. Before changing the Git working tree, the plug-in also starts an external update watchdog. On systemd installations it runs in a separate transient service, so it remains active while OSPy restarts. A non-Python supervisor keeps this protection compatible with legacy SysV OSPy services that broadly terminate Python processes during restart. The helper must publish a token-bound readiness marker before any tracked file is changed. The new OSPy process confirms the update only after a fresh scheduler heartbeat and the web interface starts listening. If a healthy start is not confirmed within 300 seconds, the watchdog resets the repository to the previous commit and restarts OSPy automatically. The five-minute window allows installations with many sequentially initialized plug-ins to reach the System Update confirmation without weakening the health checks. Systems without systemd use a detached supervised helper process. If the watchdog cannot be started or does not confirm readiness, the update is aborted before tracked files are changed. The status page displays the exact current and target commits. A verified stable release must be an annotated Git tag named `vX.Y.Z` whose commit is reachable from `origin/master`; its annotation is displayed as the release notes. Lightweight tags, non-semantic names and tags outside `master` are ignored. The project maintainer creates and pushes release tags manually only after the tested beta revision has been promoted to master. -Version 1.2.7 exposes stable mobile metric identifiers for the current and target commits, update availability and watchdog state so the native application can safely provide check and install controls. +Version 1.2.8 forces GitHub fetch operations to use HTTP/1.1 when HTTP/2 transport fails and preserves the complete Git command output in update diagnostics. It also exposes stable mobile metric identifiers for the current and target commits, update availability and watchdog state so the native application can safely provide check and install controls. The complete stable metric IDs are `enabled`, `automatic_update`, `checking`, `update_available`, `current_version`, `current_commit`, `target_commit`, `stable_release`, `upstream_branch`, `update_channel`, `update_watchdog` and `last_watchdog_result`. The plug-in card is status-only: checking, applying and rolling back updates use OSPy's separately scoped `/api/v1/updates/actions/check|apply|rollback` endpoints and asynchronous operation polling. Both manual commit rollback and rollback to the latest verified stable release now save the settings, create a verified safety backup and arm the external watchdog before changing Git files. Stable rollback checks out `master` at the verified tag commit. The plug-in never creates or trusts a release tag supplied by the browser; it refreshes and validates the repository state again when rollback is requested. For check new version OSPy click on update status button. diff --git a/plugins/system_update/__init__.py b/plugins/system_update/__init__.py index 2399591b..87f42fe0 100644 --- a/plugins/system_update/__init__.py +++ b/plugins/system_update/__init__.py @@ -214,7 +214,7 @@ def _update_rev_data(self): self.status['checking'] = True try: - run_required_command(['git', 'fetch', '--prune', '--tags', 'origin'], timeout=45) + run_required_command(git_fetch_command('--prune', '--tags', 'origin'), timeout=45) remote = git_output(['git', 'config', '--get', 'remote.origin.url']) if remote: self.status['remote'] = remote @@ -400,12 +400,40 @@ def run_command(cmd, timeout=60): def run_required_command(cmd, timeout=60): args = shlex.split(cmd) if isinstance(cmd, str) else cmd - output = subprocess.check_output(args, stderr=subprocess.STDOUT, timeout=timeout).decode('utf-8') + try: + output = subprocess.check_output( + args, + stderr=subprocess.STDOUT, + timeout=timeout + ).decode('utf-8', errors='replace') + except subprocess.CalledProcessError as error: + command_output = (error.output or b'').decode('utf-8', errors='replace').strip() + message = 'Command failed with exit code {}: {}'.format( + error.returncode, + ' '.join(str(arg) for arg in args) + ) + if command_output: + message += '\n' + command_output + raise RuntimeError(message) from error + except subprocess.TimeoutExpired as error: + command_output = (error.output or b'').decode('utf-8', errors='replace').strip() + message = 'Command timed out after {} seconds: {}'.format( + timeout, + ' '.join(str(arg) for arg in args) + ) + if command_output: + message += '\n' + command_output + raise RuntimeError(message) from error if output.strip(): log.info(NAME, output.strip()) return output.strip() +def git_fetch_command(*args): + """Build a Git fetch command that avoids unreliable HTTP/2 transports.""" + return ['git', '-c', 'http.version=HTTP/1.1', 'fetch'] + list(args) + + def _read_json(path): try: with open(path, 'r', encoding='utf-8') as source: @@ -675,7 +703,7 @@ def perform_update(): branch = selected_branch() remote_branch = selected_remote_branch() run_required_command(['git', 'config', 'core.filemode', 'false']) - run_required_command(['git', 'fetch', '--prune', 'origin'], timeout=120) + run_required_command(git_fetch_command('--prune', 'origin'), timeout=120) target_commit = git_output(['git', 'rev-parse', '--verify', remote_branch]) previous_commit = git_output(['git', 'rev-parse', 'HEAD']) from ospy.options import options as current_options @@ -920,7 +948,7 @@ def POST(self): data = web.input() verify_csrf(data) try: - run_required_command(['git', 'fetch', '--prune', '--tags', 'origin'], timeout=45) + run_required_command(git_fetch_command('--prune', '--tags', 'origin'), timeout=45) release = stable_release_info() except Exception: release = {} diff --git a/plugins/system_update/plugin.json b/plugins/system_update/plugin.json index 948fb2ea..8a58708f 100644 --- a/plugins/system_update/plugin.json +++ b/plugins/system_update/plugin.json @@ -2,7 +2,7 @@ "schema_version": 1, "id": "system_update", "name": "System Update", - "version": "1.2.7", + "version": "1.2.8", "author": "Martin Pihrt", "license": "GPL-3.0", "ospy": { 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..dbaf214b 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.1", "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..5962f6ba --- /dev/null +++ b/plugins/thermostat/static/thermostat.css @@ -0,0 +1,143 @@ +.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: bold; +} + +.thermostatCardTitle { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.thermostatCardEnabled { + color: #278636; +} + +.thermostatCardDisabled { + color: #b21f2d; +} + +.thermostatCardButton { + background: #e0a800; + border: 1px solid #b88900; + border-radius: 12px; + color: #fff; + display: inline-block; + font-size: .85em; + font-weight: bold; + padding: 3px 12px; + text-transform: uppercase; +} + +.thermostatCard summary:hover .thermostatCardButton { + background: #c99700; +} + +.thermostatAddButton { + background: #278636; + border-color: #1f6b2b; +} + +.thermostatCard summary:hover .thermostatAddButton { + background: #1f6b2b; +} + +.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..79b5f5d5 100644 --- a/plugins/thermostat/templates/thermostat.html +++ b/plugins/thermostat/templates/thermostat.html @@ -1,201 +1,177 @@ -$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']} + $if not is_new: + (${_('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_irrigation_safety.py b/tests/test_irrigation_safety.py new file mode 100644 index 00000000..faae2a32 --- /dev/null +++ b/tests/test_irrigation_safety.py @@ -0,0 +1,133 @@ +import importlib.util +import json +import pathlib +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +PLUGIN = ROOT / 'plugins' / 'irrigation_safety' +SPEC = importlib.util.spec_from_file_location( + 'irrigation_safety_model', PLUGIN / 'model.py') +model = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(model) + + +class IrrigationSafetyModelTests(unittest.TestCase): + def test_profiles_follow_enabled_non_master_stations(self): + stored = [{ + 'station_id': 2, 'enabled': True, + 'minimum_flow_lpm': '3,5', 'maximum_flow_lpm': '8.5', + }] + profiles = model.normalize_profiles(stored, [(2, 'Beds'), (5, 'Trees')]) + self.assertEqual([item['station_id'] for item in profiles], [2, 5]) + self.assertEqual(profiles[0]['name'], 'Beds') + self.assertEqual(profiles[0]['minimum_flow_lpm'], 3.5) + self.assertTrue(profiles[0]['enabled']) + self.assertFalse(profiles[1]['enabled']) + + def test_combined_range_requires_every_active_station(self): + first = model.default_profile(1, 'First') + first.update({'enabled': True, 'minimum_flow_lpm': 2, + 'maximum_flow_lpm': 5, 'startup_delay': 10, + 'confirm_seconds': 5}) + second = model.default_profile(2, 'Second') + second.update({'enabled': True, 'minimum_flow_lpm': 3, + 'maximum_flow_lpm': 8, 'startup_delay': 20, + 'confirm_seconds': 8}) + expected = model.expected_range([1, 2], [first, second]) + self.assertEqual(expected['minimum'], 5) + self.assertEqual(expected['maximum'], 13) + self.assertEqual(expected['startup_delay'], 20) + self.assertEqual(expected['confirm_seconds'], 8) + second['enabled'] = False + self.assertIsNone(model.expected_range([1, 2], [first, second])) + + def test_flow_faults_distinguish_missing_low_and_high(self): + expected = {'minimum': 5, 'maximum': 10} + self.assertEqual(model.flow_fault(None, expected), 'flow_unavailable') + self.assertEqual(model.flow_fault(0, expected), 'no_flow') + self.assertEqual(model.flow_fault(4.9, expected), 'low_flow') + self.assertIsNone(model.flow_fault(7, expected)) + self.assertEqual(model.flow_fault(10.1, expected), 'high_flow') + + def test_learning_uses_robust_percentiles_and_margin(self): + samples = [10.0] * 28 + [1.0, 100.0] + learned = model.learned_range(samples, 20, 0.5) + self.assertEqual(learned['median'], 10.0) + self.assertEqual(learned['minimum'], 8.0) + self.assertEqual(learned['maximum'], 12.0) + self.assertEqual(learned['samples'], 30) + + def test_learning_rejects_too_few_or_non_positive_samples(self): + with self.assertRaises(ValueError): + model.learned_range([0, -1, 2, 3]) + + def test_profile_validation_rejects_overlapping_limits(self): + profile = model.default_profile(0, 'Station') + profile['minimum_flow_lpm'] = 5 + profile['maximum_flow_lpm'] = 5 + with self.assertRaises(ValueError): + model.validate_profile(profile) + + def test_confirmation_is_inclusive_at_boundary(self): + self.assertFalse(model.confirmed(None, 20, 10)) + self.assertFalse(model.confirmed(10, 19.99, 10)) + self.assertTrue(model.confirmed(10, 20, 10)) + + +class IrrigationSafetyInterfaceTests(unittest.TestCase): + def test_manifest_declares_dependencies_provider_and_mobile(self): + manifest = json.loads((PLUGIN / 'plugin.json').read_text(encoding='utf-8')) + self.assertEqual(manifest['id'], 'irrigation_safety') + self.assertEqual(manifest['version'], '1.0.0') + self.assertEqual(manifest['ospy']['min'], '3.0.354') + self.assertEqual(manifest['provider']['contract'], 'ospy.provider.v1') + dependencies = {item['id']: item['required'] + for item in manifest['dependencies']} + self.assertTrue(dependencies['water_meter']) + self.assertFalse(dependencies['pressure_monitor']) + self.assertEqual(manifest['mobile']['api_version'], 1) + + def test_page_uses_slider_switches_cards_and_background_polling(self): + template = (PLUGIN / 'templates' / 'irrigation_safety.html').read_text( + encoding='utf-8') + script = (PLUGIN / 'static' / 'irrigation_safety.js').read_text( + encoding='utf-8') + source = (PLUGIN / '__init__.py').read_text(encoding='utf-8') + self.assertIn('class="safetySlider"', template) + self.assertIn('class="safetyCard"', template) + self.assertIn("('monitor', _('Monitor only'))", source) + self.assertIn("('protect', _('Active protection'))", source) + self.assertIn('start_learning', template) + self.assertIn('acknowledge', template) + self.assertIn('status_json', template) + self.assertIn('window.setInterval(poll, 2000)', script) + self.assertIn('textContent', script) + self.assertNotIn('innerHTML', script) + self.assertNotIn('