diff --git a/alpaca/__init__.py b/alpaca/__init__.py index 4b2314f..f208008 100644 --- a/alpaca/__init__.py +++ b/alpaca/__init__.py @@ -2,6 +2,7 @@ Alpaca Package - ASCOM Alpaca SafetyMonitor """ import os +from dataclasses import fields from flask import Flask from flask_cors import CORS from .config import AlpacaConfig @@ -22,7 +23,8 @@ def create_app(): port=int(os.getenv('ALPACA_PORT', '11111')), device_number=int(os.getenv('ALPACA_DEVICE_NUMBER', '0')), detection_interval=int(os.getenv('DETECT_INTERVAL', '30')), - update_interval=int(os.getenv('ALPACA_UPDATE_INTERVAL', '30')) + update_interval=int(os.getenv('ALPACA_UPDATE_INTERVAL', '30')), + max_image_age_sec=int(os.getenv('MAX_IMAGE_AGE_SEC', '600')) ) # 2. Load from file and override environment settings if file exists @@ -33,7 +35,11 @@ def create_app(): # Update our base config with ONLY the values explicitly in the file for key, value in file_settings.items(): setattr(alpaca_cfg, key, value) - + # A hand-edited JSON file may hold "300" for an int field; coerce once here + for f in fields(alpaca_cfg): + if f.type is int: + setattr(alpaca_cfg, f.name, int(getattr(alpaca_cfg, f.name))) + # 3. Save the final configuration to ensure the file exists and is current alpaca_cfg.save_to_file() diff --git a/alpaca/config.py b/alpaca/config.py index 43204cb..b2b086e 100644 --- a/alpaca/config.py +++ b/alpaca/config.py @@ -80,6 +80,9 @@ class AlpacaConfig: debounce_to_safe_sec: int = 60 # Wait time before switching from Unsafe → Safe debounce_to_unsafe_sec: int = 0 # Wait time before switching from Safe → Unsafe (immediate) + # Stale image fail-safe: force unsafe if the image is unchanged this long (0 = disabled) + max_image_age_sec: int = 600 + # NTP and timezone settings ntp_server: str = field(default_factory=lambda: os.environ.get('NTP_SERVER', 'pool.ntp.org')) timezone: str = field(default_factory=lambda: os.environ.get('TZ', 'UTC')) diff --git a/alpaca/device.py b/alpaca/device.py index 6a72751..de4f7f1 100644 --- a/alpaca/device.py +++ b/alpaca/device.py @@ -50,7 +50,6 @@ def __init__(self, alpaca_config: AlpacaConfig, detect_config: DetectConfig): 'Detection Time (Seconds)': 0.0, 'timestamp': None } - self._cached_is_safe = False self._unsafe_conditions_set = set(alpaca_config.unsafe_conditions) # Debounce state tracking @@ -58,6 +57,15 @@ def __init__(self, alpaca_config: AlpacaConfig, detect_config: DetectConfig): self._pending_safe_state: Optional[bool] = None self._state_change_start_time: Optional[datetime] = None + # Stale image tracking: hash of the last image and when it last changed. + # changed_at stays None until a second distinct hash is seen after start. + self._last_image_hash: Optional[str] = None + self._image_hash_changed_at: Optional[datetime] = None + self._warned_no_hash = False + self._hash_missing = False + # Set by _demote() under the lock, read by _run_single_detection() after release + self._demoted = False + # Safety state history (last 100 transitions) self._safety_history = deque(maxlen=100) @@ -80,7 +88,7 @@ def __init__(self, alpaca_config: AlpacaConfig, detect_config: DetectConfig): # Setup HA Discovery if enabled if self.mqtt_client and self.detect_config.mqtt_discovery_mode == 'homeassistant': - self.ha_discovery = HADiscoveryManager(self.detect_config, self.mqtt_client) + self.ha_discovery = HADiscoveryManager(self.detect_config, self.mqtt_client, safe_sensor=True) self.ha_discovery.publish_discovery_configs() # Blocking initial detection to ensure readiness @@ -102,6 +110,11 @@ def _create_thumbnail_bytes(self, img: Image.Image) -> Optional[bytes]: def _update_cached_safety(self, detection: Dict[str, Any]): """Update cached safety status with debouncing logic (assumes lock is held)""" + # latest_detection still holds the previous result here, so this catches + # a gap left by failed cycles or a hung detection thread + if self.latest_detection.get('timestamp') is not None: + self._demote_if_stale() + # Step A: Determine Instantaneous Safety class_name = detection.get('class_name', '') confidence = detection.get('confidence_score', 0.0) @@ -119,12 +132,40 @@ def _update_cached_safety(self, detection: Dict[str, Any]): class_name not in self._unsafe_conditions_set ) + # A source image that has stopped changing is never safe: commit UNSAFE + # at once and skip the debounce below, so no pending-safe timer runs + # while the image is stale. Recovery still needs debounce_to_safe_sec. + max_age = self.alpaca_config.max_image_age_sec + image_hash = detection.get('image_hash') + self._hash_missing = image_hash is None + if image_hash is None: + if max_age > 0 and not self._warned_no_hash: + self._warned_no_hash = True + logger.warning("image_hash missing from detector result: stale-image check disabled") + else: + # Hash history is kept even with the check disabled, so enabling it + # at runtime does not restart the "no change since start" rule + now = get_current_time(self.alpaca_config.timezone) + if self._last_image_hash is not None and image_hash != self._last_image_hash: + self._image_hash_changed_at = now + self._last_image_hash = image_hash + if max_age > 0 and image_hash is not None: + if self._image_hash_changed_at is None: + stale = "no image change seen since start" + else: + unchanged_sec = (now - self._image_hash_changed_at).total_seconds() + stale = f"image unchanged for {unchanged_sec:.0f}s, max {max_age}s" if unchanged_sec > max_age else None + if stale: + if is_safe_now: + logger.info(f"Stale Image: {stale}; holding UNSAFE") + self._demote('Stale Image', stale) + return + # Step B: Apply Debouncing if is_safe_now == self._stable_safe_state: # State matches - reset any pending changes self._pending_safe_state = None self._state_change_start_time = None - self._cached_is_safe = self._stable_safe_state else: # State differs from stable state if self._pending_safe_state != is_safe_now: @@ -147,7 +188,6 @@ def _update_cached_safety(self, detection: Dict[str, Any]): if elapsed_time >= required_duration: # Debounce period complete - commit state change self._stable_safe_state = is_safe_now - self._cached_is_safe = is_safe_now self._pending_safe_state = None self._state_change_start_time = None @@ -158,7 +198,7 @@ def _update_cached_safety(self, detection: Dict[str, Any]): 'condition': class_name, 'confidence': confidence }) - + logger.warning(f"SAFETY STATE CHANGED: {'SAFE' if is_safe_now else 'UNSAFE'} " f"(class={class_name}, confidence={confidence:.1f}%, " f"threshold={threshold:.1f}%, debounce={elapsed_time:.1f}s)") @@ -246,6 +286,7 @@ def on_connect(client, userdata, flags, rc, properties=None): def _run_single_detection(self, initial: bool = False): """Run a single detection cycle with optimized image handling""" + self._demoted = False try: # Return image so we can process thumbnail result = self.cloud_detector.detect(return_image=True) @@ -266,9 +307,11 @@ def _run_single_detection(self, initial: bool = False): del result['image'] with self.detection_lock: + self._update_cached_safety(result) + # Hash is only needed for the stale check; keep it out of API and MQTT output + result.pop('image_hash', None) self.latest_detection = result self.latest_image_bytes = image_bytes - self._update_cached_safety(result) if initial: self._safety_history.append({ @@ -278,22 +321,28 @@ def _run_single_detection(self, initial: bool = False): 'confidence': result.get('confidence_score', 0.0) }) - # MQTT Publish + # MQTT Publish (outside the lock: is_safe() takes it) if self.mqtt_client: try: mqtt_result = result.copy() if 'timestamp' in mqtt_result and isinstance(mqtt_result['timestamp'], datetime): mqtt_result['timestamp'] = mqtt_result['timestamp'].isoformat() - + if self.detect_config.mqtt_discovery_mode == 'homeassistant': self.ha_discovery.publish_states(mqtt_result) else: + mqtt_result['is_safe'] = self.is_safe() self.mqtt_client.publish(self.detect_config.topic, json.dumps(mqtt_result)) except Exception as e: logger.error(f"MQTT publish failed: {e}") + self._publish_safety(data_online=not self._demoted) except Exception as e: logger.error(f"Detection cycle failed: {e}") + with self.detection_lock: + self._demote_if_stale() + if self._demoted: + self._publish_safety(data_online=False) def _detection_loop(self): """Background thread for continuous cloud detection""" @@ -443,13 +492,78 @@ def disconnect(self, client_ip: str = None, client_id: int = None): def is_safe(self) -> bool: """Debounced safety state of the sky, independent of ASCOM client connections. - The Alpaca routes apply the per-client Connected gate themselves - (see routes/api.py client_is_safe). Everything else (external REST - API, dashboard, MQTT) reports this value directly. + Served as-is by the Alpaca IsSafe route, the external REST API, the + dashboard and MQTT. The ASCOM spec never requires False when no client + is connected (ConformU dropped that check on 2025-03-16). """ with self.detection_lock: - return self._stable_safe_state + return self._stable_safe_state and self._detection_is_fresh() + + def _demote(self, condition: str, detail: str): + """Commit UNSAFE now and drop any pending debounce (assumes lock is held). + + Recovery then has to pass debounce_to_safe_sec like any other transition. + """ + self._demoted = True + self._pending_safe_state = None + self._state_change_start_time = None + if self._stable_safe_state: + self._stable_safe_state = False + self._safety_history.append({ + 'timestamp': get_current_time(self.alpaca_config.timezone), + 'is_safe': False, + 'condition': condition, + 'confidence': 0.0 + }) + logger.warning(f"SAFETY STATE CHANGED: UNSAFE (class={condition}, {detail})") + + def _demote_if_stale(self): + """Demote once the last successful detection is too old (assumes lock is held)""" + if not self._detection_is_fresh(): + self._demote('Detection Stale', f"last success {self.latest_detection.get('timestamp')}") + + def _publish_safety(self, data_online: bool): + """Publish HA data availability and the IsSafe binary sensor. Never call under detection_lock.""" + if not self.ha_discovery: + return + try: + self.ha_discovery.publish_data_availability(data_online) + self.ha_discovery.publish_safe(self.is_safe()) + except Exception as e: + logger.error(f"MQTT publish failed: {e}") + + def _detection_is_fresh(self) -> bool: + """True if the last successful detection is recent enough to trust (assumes lock is held). + + Fails safe when detect() keeps raising: latest_detection stops updating, + so its timestamp ages past the limit. + """ + timestamp = self.latest_detection.get('timestamp') if self.latest_detection else None + if timestamp is None: + return False + max_age = max(180, 3 * self.alpaca_config.update_interval) + return (get_current_time(self.alpaca_config.timezone) - timestamp).total_seconds() <= max_age + def get_image_status(self) -> Dict[str, Any]: + """Stale-image check state for the dashboard, from the same fields the demote logic uses""" + with self.detection_lock: + now = get_current_time(self.alpaca_config.timezone) + limit = self.alpaca_config.max_image_age_sec + changed = self._image_hash_changed_at is not None + unchanged_sec = (now - self._image_hash_changed_at).total_seconds() if changed else None + last_ts = self.latest_detection.get('timestamp') + return { + 'check_enabled': limit > 0, + 'limit_sec': limit, + 'unchanged_sec': unchanged_sec, + 'changed_since_start': changed, + 'hash_missing': self._hash_missing, + # A missing hash disables the check (see _update_cached_safety), so never report stale then + 'stale': limit > 0 and not self._hash_missing and (not changed or unchanged_sec > limit), + 'detection_fresh': self._detection_is_fresh(), + 'detection_age_sec': (now - last_ts).total_seconds() if last_ts else None, + } + def get_pending_status(self) -> Dict[str, Any]: """Get information about any pending state changes""" with self.detection_lock: diff --git a/alpaca/routes/api.py b/alpaca/routes/api.py index 066b491..b0a382b 100644 --- a/alpaca/routes/api.py +++ b/alpaca/routes/api.py @@ -53,19 +53,6 @@ def endpoint(device_number: int): )) return endpoint -def client_is_safe() -> bool: - """IsSafe as reported to the calling Alpaca client. - - Alpaca tracks Connected per client (IP + ClientID). A client that has not - connected is told False, matching the pre-connection behaviour ASCOM - clients have always seen from this driver. The detector itself keeps - running; monitor.is_safe() carries the real state for non-Alpaca consumers. - """ - client_id, _ = monitor.get_client_params() - if not monitor.is_client_connected(request.remote_addr, client_id): - return False - return monitor.is_safe() - @api_bp.route('/v1/safetymonitor//issafe', methods=['GET']) def get_issafe(device_number: int): """Get safety status""" @@ -74,7 +61,7 @@ def get_issafe(device_number: int): return error_response _, client_tx_id = monitor.get_client_params() return jsonify(monitor.create_response( - value=client_is_safe(), + value=monitor.is_safe(), client_transaction_id=client_tx_id )) @@ -200,7 +187,7 @@ def get_description(device_number: int): @api_bp.route('/v1/safetymonitor//devicestate', methods=['GET']) def get_devicestate(device_number: int): return create_simple_get_endpoint( - lambda: [{"Name": "IsSafe", "Value": client_is_safe()}] + lambda: [{"Name": "IsSafe", "Value": monitor.is_safe()}] )(device_number) @api_bp.route('/v1/safetymonitor//driverinfo', methods=['GET']) diff --git a/alpaca/routes/external_api.py b/alpaca/routes/external_api.py index 72ea19b..0f217c2 100644 --- a/alpaca/routes/external_api.py +++ b/alpaca/routes/external_api.py @@ -51,7 +51,8 @@ def get_status(): return jsonify({ "is_safe": is_safe, "safety_status": "Safe" if is_safe else "Unsafe", - "detection": detection + "detection": detection, + "image": monitor.get_image_status() }) @external_api_bp.route('/api/ext/v1/config', methods=['GET']) diff --git a/alpaca/routes/management.py b/alpaca/routes/management.py index d0d4c18..6a8b2ee 100644 --- a/alpaca/routes/management.py +++ b/alpaca/routes/management.py @@ -20,6 +20,52 @@ def init_mgmt(safety_monitor_instance): global monitor monitor = safety_monitor_instance + +def _fmt_seconds(sec): + s = max(0, int(round(sec))) + if s < 60: + return f"{s}s" + m = s // 60 + if m < 60: + return f"{m}m {s % 60}s" + return f"{m // 60}h {m % 60}m" + + +_GREY = "rgb(100, 116, 139)" +_GREEN = "rgb(52, 211, 153)" +_AMBER = "rgb(251, 191, 36)" +_RED = "rgb(248, 113, 113)" + + +def image_status_view(status, is_safe=False, detection_limit_sec=None): + """Dashboard value line, grey subtext and colour for monitor.get_image_status().""" + def view(text, subtext, color): + return {"text": text, "subtext": subtext, "color": color} + + if not status: + return view("...", "", _GREY) + unchanged = status.get("unchanged_sec") + limit = status.get("limit_sec") or 0 + limit_text = f"Limit {_fmt_seconds(limit)}" + if not status.get("detection_fresh", True): + age = status.get("detection_age_sec") + text = f"No detection for {_fmt_seconds(age)}" if age is not None else "No detection yet" + return view(text, f"Limit {_fmt_seconds(detection_limit_sec)}" if detection_limit_sec else "", _RED) + if not status.get("check_enabled"): + return view("Stale check off", "Limit disabled", _GREY) + if status.get("hash_missing"): + return view("Stale check off", "No image hash from detector", _GREY) + if status.get("stale"): + if status.get("changed_since_start") and unchanged is not None: + if is_safe: # demote lands on the next detection cycle + return view(f"Changed: {_fmt_seconds(unchanged)} ago", f"{limit_text}, unsafe on next detection", _RED) + return view(f"STALE: unchanged {_fmt_seconds(unchanged)}", limit_text, _RED) + return view("STALE: no new frame since start", limit_text, _RED) + if unchanged is None: + return view("Waiting for first image", "", _GREY) + color = _AMBER if unchanged > limit / 2 else _GREEN + return view(f"Changed: {_fmt_seconds(unchanged)} ago", limit_text, color) + @mgmt_bp.route('/setup/v1/safetymonitor//setup', methods=['GET', 'POST']) def setup_device(device_number: int): """Setup page for device configuration""" @@ -39,7 +85,8 @@ def setup_device(device_number: int): monitor.alpaca_config.update_interval = int(request.form.get('update_interval', monitor.alpaca_config.update_interval)) monitor.alpaca_config.debounce_to_safe_sec = int(request.form.get('debounce_safe', monitor.alpaca_config.debounce_to_safe_sec)) monitor.alpaca_config.debounce_to_unsafe_sec = int(request.form.get('debounce_unsafe', monitor.alpaca_config.debounce_to_unsafe_sec)) - + monitor.alpaca_config.max_image_age_sec = int(request.form.get('max_image_age', monitor.alpaca_config.max_image_age_sec)) + # Update unsafe conditions based on radio buttons new_unsafe = [] for condition in ALL_CLOUD_CONDITIONS: @@ -86,7 +133,9 @@ def setup_device(device_number: int): # Get pending status pending_status = monitor.get_pending_status() - + + image_status = monitor.get_image_status() + # Format timestamp if timestamp: last_update = timestamp.strftime("%Y-%m-%d %H:%M:%S") @@ -233,6 +282,7 @@ def setup_device(device_number: int): update_interval=monitor.alpaca_config.update_interval, debounce_to_safe=monitor.alpaca_config.debounce_to_safe_sec, debounce_to_unsafe=monitor.alpaca_config.debounce_to_unsafe_sec, + max_image_age=monitor.alpaca_config.max_image_age_sec, current_condition=current_condition, current_confidence=current_confidence, detection_time=detection_time, @@ -249,7 +299,9 @@ def setup_device(device_number: int): unsafe_conditions=unsafe_cond, default_threshold=monitor.alpaca_config.default_threshold, class_thresholds=monitor.alpaca_config.class_thresholds, - pending_status=pending_status + pending_status=pending_status, + image_status_view=image_status_view(image_status, is_safe, + max(180, 3 * monitor.alpaca_config.update_interval)) ) diff --git a/detect.py b/detect.py index 8d7649b..381ea5d 100755 --- a/detect.py +++ b/detect.py @@ -6,6 +6,7 @@ import time import json import gc +import hashlib import io import urllib.parse from dataclasses import dataclass @@ -87,13 +88,20 @@ def from_env(cls) -> 'Config': class HADiscoveryManager: """Manages Home Assistant MQTT Discovery""" - def __init__(self, config: Config, mqtt_client): + def __init__(self, config: Config, mqtt_client, safe_sensor: bool = False): self.config = config self.mqtt_client = mqtt_client + # Only the Alpaca monitor has a safety state to publish; standalone detect.py does not + self.safe_sensor = safe_sensor self.device_id = config.device_id self.discovery_prefix = config.mqtt_discovery_prefix self.availability_topic = f"{self.discovery_prefix}/sensor/clouddetect_{self.device_id}/availability" - + # Second availability topic: offline while results are stale or failing. + # The LWT topic above only tracks the MQTT session. + self.data_availability_topic = f"{self.discovery_prefix}/sensor/clouddetect_{self.device_id}/data_availability" + self.safe_topic = f"{self.discovery_prefix}/binary_sensor/clouddetect_{self.device_id}/safe/state" + self.data_online = True + def get_device_info(self) -> dict: """Get device information dict for HA discovery""" return { @@ -107,13 +115,16 @@ def get_device_info(self) -> dict: def publish_discovery_configs(self): """Publish discovery configuration for all sensors""" device_info = self.get_device_info() - + # Sensors need both the MQTT session and current data + availability = [{"topic": self.availability_topic}, {"topic": self.data_availability_topic}] + # Cloud Status Sensor status_config = { "name": "Status", "unique_id": f"clouddetect_{self.device_id}_status", "state_topic": f"{self.discovery_prefix}/sensor/clouddetect_{self.device_id}/status/state", - "availability_topic": self.availability_topic, + "availability": availability, + "availability_mode": "all", "icon": "mdi:weather-cloudy", "device": device_info } @@ -128,7 +139,8 @@ def publish_discovery_configs(self): "name": "Confidence", "unique_id": f"clouddetect_{self.device_id}_confidence", "state_topic": f"{self.discovery_prefix}/sensor/clouddetect_{self.device_id}/confidence/state", - "availability_topic": self.availability_topic, + "availability": availability, + "availability_mode": "all", "unit_of_measurement": "%", "icon": "mdi:percent", "device": device_info @@ -144,7 +156,8 @@ def publish_discovery_configs(self): "name": "Detection Time", "unique_id": f"clouddetect_{self.device_id}_detection_time", "state_topic": f"{self.discovery_prefix}/sensor/clouddetect_{self.device_id}/detection_time/state", - "availability_topic": self.availability_topic, + "availability": availability, + "availability_mode": "all", "unit_of_measurement": "s", "device_class": "duration", "icon": "mdi:timer", @@ -156,10 +169,38 @@ def publish_discovery_configs(self): retain=True ) - # Publish availability as online + # IsSafe binary sensor. Only tied to the session: it reads OFF, not + # unavailable, while data is stale. No device_class (HA "safety" means ON = unsafe). + if self.safe_sensor: + safe_config = { + "name": "Safe", + "unique_id": f"clouddetect_{self.device_id}_safe", + "state_topic": self.safe_topic, + "payload_on": "ON", + "payload_off": "OFF", + "availability": [{"topic": self.availability_topic}], + "device": device_info + } + self.mqtt_client.publish( + f"{self.discovery_prefix}/binary_sensor/clouddetect_{self.device_id}/safe/config", + json.dumps(safe_config), + retain=True + ) + + # Publish availability as online (data: last known state, for reconnects) self.mqtt_client.publish(self.availability_topic, "online", retain=True) + self.publish_data_availability(self.data_online) logger.info("Published HA discovery configurations") - + + def publish_data_availability(self, online: bool): + """Mark sensor data current (online) or stale/failing (offline)""" + self.data_online = online + self.mqtt_client.publish(self.data_availability_topic, "online" if online else "offline", retain=True) + + def publish_safe(self, is_safe: bool): + """Publish the IsSafe binary sensor state""" + self.mqtt_client.publish(self.safe_topic, "ON" if is_safe else "OFF", retain=True) + def publish_states(self, result: dict): """Publish individual sensor states for HA discovery mode""" # Publish cloud status @@ -259,8 +300,12 @@ def on_connect(client, userdata, flags, rc, properties=None): logger.error(f"Failed to connect to MQTT broker: {e}") raise - def _load_image(self, image_url: str, max_retries: int = 3) -> Image.Image: - """Load and return image from URL or file with retry logic and strict timeouts""" + def _load_image(self, image_url: str, max_retries: int = 3) -> tuple: + """Load image from URL or file with retry logic and strict timeouts. + + Returns (image, sha1 hex digest of the raw bytes). The hash lets the + caller detect a source image that has stopped changing. + """ for attempt in range(max_retries): try: if image_url.startswith("file://"): @@ -274,14 +319,14 @@ def _load_image(self, image_url: str, max_retries: int = 3) -> Image.Image: file_path = Path(path_str) if not file_path.exists(): raise FileNotFoundError(f"Image file not found: {file_path}") - with open(file_path, 'rb') as f: - return Image.open(f).convert("RGB") + data = file_path.read_bytes() else: # FIX: Strict timeout (5s connect, 10s read) and close socket immediately with self.session.get(image_url, timeout=(5, 10), stream=True, verify=self.config.verify_ssl) as response: response.raise_for_status() # Load into memory buffer to allow socket to close - return Image.open(io.BytesIO(response.content)).convert("RGB") + data = response.content + return Image.open(io.BytesIO(data)).convert("RGB"), hashlib.sha1(data).hexdigest() except (requests.RequestException, IOError) as e: if attempt == max_retries - 1: # Last attempt logger.error(f"Failed to load image from {image_url} after {max_retries} attempts: {e}") @@ -307,7 +352,7 @@ def detect(self, return_image: bool = False) -> dict: try: # Load and preprocess image - image = self._load_image(self.config.image_url) + image, image_hash = self._load_image(self.config.image_url) preprocessed_image = self._preprocess_image(image) # Make prediction @@ -332,7 +377,8 @@ def detect(self, return_image: bool = False) -> dict: result = { "class_name": class_name, "confidence_score": round(confidence_score * 100, 2), - "Detection Time (Seconds)": round(elapsed_time, 2) + "Detection Time (Seconds)": round(elapsed_time, 2), + "image_hash": image_hash } # Optionally include the raw image before cleanup @@ -375,6 +421,7 @@ def run_detection_loop(self): while True: try: result = self.detect() + result.pop('image_hash', None) self.publish_result(result) time.sleep(self.config.detect_interval) except Exception as e: diff --git a/docs/EXTERNAL_API.md b/docs/EXTERNAL_API.md index 00bc22a..1064bfd 100644 --- a/docs/EXTERNAL_API.md +++ b/docs/EXTERNAL_API.md @@ -114,7 +114,7 @@ Returns current safety status and latest detection results. | Field | Type | Description | |-------|------|-------------| -| `is_safe` | boolean | Debounced detector state. Does not depend on any ASCOM client being connected. | +| `is_safe` | boolean | Debounced detector state. Does not depend on any ASCOM client being connected. The Alpaca `issafe` endpoint returns the same value. | | `safety_status` | string | "Safe" or "Unsafe" | | `detection.class_name` | string | Detected condition (Clear, Wisps, Mostly Cloudy, etc.) | | `detection.confidence_score` | number | ML model confidence (0.0 - 1.0) | diff --git a/readme.md b/readme.md index 9528a85..324f545 100644 --- a/readme.md +++ b/readme.md @@ -155,6 +155,7 @@ docker pull chvvkumar/simpleclouddetect:latest | `ALPACA_PORT` | `11111` | HTTP API port | | `ALPACA_DEVICE_NUMBER` | `0` | Device number | | `ALPACA_UPDATE_INTERVAL` | `30` | Update interval in seconds | +| `MAX_IMAGE_AGE_SEC` | `600` | Report unsafe if the source image is unchanged for this many seconds. `0` disables the check. Applies only until `alpaca_config.json` exists; afterwards use the setup page or the JSON key `max_image_age_sec` | > **Note:** For detailed Alpaca configuration, see **[ALPACA_README.md](ALPACA_README.md)** @@ -356,7 +357,9 @@ When using `MQTT_DISCOVERY_MODE=homeassistant`, your device automatically appear - **Cloud Status** - Current sky condition - **Confidence** - Detection confidence (%) - **Detection Time** - Processing time (seconds) -- Availability tracking (online/offline status) +- One binary sensor: + - **Safe** (`binary_sensor._safe`) - `ON` when the monitor reports safe, `OFF` when unsafe or when data is stale (see [Stale Data Fail-Safes](#stale-data-fail-safes)) +- Availability tracking (online/offline status). The three sensors also go `unavailable` while data is stale - Proper device grouping in HA UI **Setup Steps:** @@ -396,6 +399,8 @@ mqtt: unit_of_measurement: "s" ``` +The Alpaca server also adds `is_safe` (`true`/`false`) to this payload. It is sent only on successful detection cycles, so treat a missing update for more than 3 minutes as unsafe. + --- ## ASCOM Alpaca SafetyMonitor @@ -409,6 +414,36 @@ The container includes an ASCOM Alpaca SafetyMonitor service (Interface Version 3. **Configure Device**: Set name, location, and unsafe conditions 4. **Add to Software**: Configure in N.I.N.A., SGP, TheSkyX, etc. +### Stale Data Fail-Safes + +`IsSafe` reports `False` when the input data can no longer be trusted: + +1. **Unchanged image**: each detection hashes the raw image bytes. If the hash does not change for longer than `max_image_age_sec` (default 600), the monitor commits unsafe immediately. The Unsafe Wait Time does not apply. Recovery requires a changed image followed by the normal Safe Wait Time. Set to `0` to disable, for example when the camera serves a static test image. +2. **Failed detections**: if no detection has succeeded within `max(180, 3 x ALPACA_UPDATE_INTERVAL)` seconds, for example because the image URL returns errors or times out, `IsSafe` reports `False` and the stable state is committed to unsafe. After detections succeed again, the normal Safe Wait Time applies before `IsSafe` returns `True`. This limit is not configurable. + +The Safety History on the setup page records these events with the condition `Stale Image` or `Detection Stale`. + +Configuration: + +- Setup page: Timing Configuration, "Stale Image Limit (seconds)". +- JSON: key `max_image_age_sec` in `alpaca_config.json`. +- Environment: `MAX_IMAGE_AGE_SEC` applies only until `alpaca_config.json` exists. Startup writes the full configuration to that file, and the file takes precedence afterwards. Once the file exists, use the setup page or edit the JSON key. + +Notes: + +- Set the limit higher than the camera's longest gap between frames plus `ALPACA_UPDATE_INTERVAL`. A lower value forces unsafe between normal image updates. +- A camera that stops writing images, for example with daytime capture disabled, reads unsafe once the limit elapses. Raise the limit or set it to `0` if that is not the intended behaviour. +- After a restart, the monitor does not report safe until the image has changed at least once since boot. With `max_image_age_sec` set to `0`, this startup requirement is also disabled. +- If a detector does not supply an image hash (for example a model backend that lacks hash support), the image check is disabled and a warning is logged at startup. The detection failure check still applies. + +Both checks apply to the ASCOM `IsSafe` value, the dashboard, and the external REST API. + +Home Assistant behaviour while data is stale: + +- The Cloud Status, Confidence, and Detection Time sensors become `unavailable`. They use a second availability topic that reports `offline` when either check trips and `online` on the next successful detection. +- The `Safe` binary sensor stays available and reads `OFF`. It follows `IsSafe` each cycle and reads `ON` only when the monitor reports safe. +- Legacy MQTT mode publishes `is_safe` in the JSON payload only on successful detection cycles, so a consumer receives nothing while detections fail and must treat a missing update for more than 3 minutes as unsafe. + ### Supported Software diff --git a/templates/setup.html b/templates/setup.html index c6e2521..2776d22 100644 --- a/templates/setup.html +++ b/templates/setup.html @@ -245,15 +245,17 @@ .info-tooltip:hover::after { content: attr(data-tooltip); position: absolute; - left: 100%; - top: 50%; - transform: translateY(-50%); - margin-left: 10px; + right: 0; + top: 100%; + margin-top: 6px; + width: max-content; + max-width: 280px; padding: 8px 12px; background-color: rgb(30, 41, 59); color: rgb(226, 232, 240); border-radius: 6px; - white-space: nowrap; + white-space: normal; + text-align: left; font-size: 11px; font-weight: normal; text-transform: none; @@ -781,7 +783,7 @@

☁️ SimpleCloudDetect

Safety State - ⓘ + ⓘ
{{ ascom_safe_status }}
@@ -791,7 +793,7 @@

☁️ SimpleCloudDetect

Last Updated
-
{{ last_update }}
+
{{ last_update }}
{% if pending_status.is_pending %} ⏳ {{ pending_status.target_state }} @@ -801,13 +803,25 @@

☁️ SimpleCloudDetect

{% endif %}
+
+
+ Image + ⓘ +
+
{{ image_status_view.text }}
+ {% if image_status_view.subtext %} +
+ {{ image_status_view.subtext }} +
+ {% endif %} +
Container Uptime
{{ container_uptime }}
- +
📜 Safety History
@@ -952,14 +966,14 @@

☁️ SimpleCloudDetect

.then(html => { const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); - + // Update Current Detection - all fields including Detection Time and Last Updated const detectionGrid = document.querySelector('.detection-grid'); const newDetectionGrid = doc.querySelector('.detection-grid'); if (detectionGrid && newDetectionGrid) { detectionGrid.innerHTML = newDetectionGrid.innerHTML; } - + // Update Safety History - find by looking for the title text const statusCards = document.querySelectorAll('.status-card'); const newStatusCards = doc.querySelectorAll('.status-card'); @@ -1243,8 +1257,28 @@

+ + +
+
+
⏳
+
+
STALE IMAGE LIMIT
+
600s
+
+
+
+ Unsafe if the camera image has not changed for this long. Fixed companion rule: unsafe if no detection succeeds for max(180s, 3 x ASCOM interval). +
+
+
- + + +
+ Fail-safes. Two checks force Unsafe independently of the detected condition: the Stale Image Limit (configurable below) when the source image stops changing, and the detection failure limit, fixed at max(180 s, 3 x ASCOM Update Interval), when the image server is unreachable or detection errors. Recovery from either always waits the Safe Wait Time. The Safety History shows "Stale Image" or "Detection Stale" as the condition when one of these triggers. +
+
💡 EXAMPLE SCENARIO
@@ -1299,14 +1333,30 @@

Delay before reporting unsafe conditions (0 = immediate)

- + +
+
+ + +

Unsafe if the source image has not changed for this long. 0 disables. Must exceed the camera's longest gap between frames plus the ASCOM Update Interval. A camera that stops writing images (for example daytime capture disabled) reads unsafe unless this is raised or set to 0. After a restart, safe requires one new frame first.

+
+
+