584 lines
21 KiB
Python
584 lines
21 KiB
Python
"""Runtime detection logic for Elegoo spaghetti detection."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from collections.abc import Callable
|
|
from datetime import datetime, timedelta
|
|
import logging
|
|
from typing import Any
|
|
|
|
import aiohttp
|
|
from homeassistant.config_entries import ConfigEntry
|
|
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
|
from homeassistant.exceptions import HomeAssistantError
|
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
|
from homeassistant.helpers.event import (
|
|
async_track_state_change_event,
|
|
async_track_time_interval,
|
|
)
|
|
from homeassistant.util import dt as dt_util
|
|
|
|
from .const import (
|
|
ATTR_CONFIDENCE,
|
|
ATTR_DETECTED,
|
|
ATTR_DETECTIONS,
|
|
ATTR_IMAGE_URL,
|
|
ATTR_LAST_ERROR,
|
|
ATTR_LAST_RUN,
|
|
ATTR_NEXT_RUN,
|
|
ATTR_RAW_SCORE,
|
|
ATTR_STATUS,
|
|
CONF_ACTIVE_PRINT_STATES,
|
|
CONF_CAMERA,
|
|
CONF_CHAMBER_LIGHT,
|
|
CONF_COOLDOWN_SECONDS,
|
|
CONF_DETECTION_INTERVAL,
|
|
CONF_FAILURE_THRESHOLD,
|
|
CONF_HOME_ASSISTANT_HOST,
|
|
CONF_INSTANCE_ID,
|
|
CONF_LIGHT_CONTROL_MODE,
|
|
CONF_LIGHT_SETTLE_SECONDS,
|
|
CONF_OBICO_AUTH_TOKEN,
|
|
CONF_OBICO_HOST,
|
|
CONF_PRINT_STATUS_SENSOR,
|
|
CONF_RUN_WITHOUT_PRINTING,
|
|
CONF_SENSITIVITY,
|
|
CONF_SNAPSHOT_URL,
|
|
CONF_WARNING_THRESHOLD,
|
|
DEFAULT_ACTIVE_PRINT_STATES,
|
|
DEFAULT_COOLDOWN_SECONDS,
|
|
DEFAULT_DETECTION_INTERVAL,
|
|
DEFAULT_FAILURE_THRESHOLD,
|
|
DEFAULT_LIGHT_CONTROL_MODE,
|
|
DEFAULT_LIGHT_SETTLE_SECONDS,
|
|
DEFAULT_SENSITIVITY,
|
|
DEFAULT_WARNING_THRESHOLD,
|
|
DOMAIN,
|
|
EVENT_DETECTION_RESULT,
|
|
EVENT_SPAGHETTI_DETECTED,
|
|
LIGHT_CONTROL_LEAVE_ON,
|
|
LIGHT_CONTROL_OFF,
|
|
LIGHT_CONTROL_RESTORE,
|
|
RUNTIME_ML_LOCK,
|
|
SENSITIVITY_THRESHOLDS,
|
|
)
|
|
|
|
LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
def _parse_states(value: str | None) -> set[str]:
|
|
"""Parse a comma-separated list of states."""
|
|
if not value:
|
|
value = DEFAULT_ACTIVE_PRINT_STATES
|
|
return {item.strip().lower() for item in value.split(",") if item.strip()}
|
|
|
|
|
|
def _normalize_state(value: Any) -> str:
|
|
"""Normalize a Home Assistant state string for comparisons."""
|
|
return str(value).strip().lower()
|
|
|
|
|
|
def _score_detections(result: dict[str, Any]) -> tuple[float, int]:
|
|
"""Return a simple confidence score from the Obico detection payload."""
|
|
score = 0.0
|
|
detections = result.get("detections") or []
|
|
for detection in detections:
|
|
try:
|
|
score += float(detection[1])
|
|
except (TypeError, ValueError, IndexError):
|
|
continue
|
|
return min(1.0, max(0.0, score)), len(detections)
|
|
|
|
|
|
class SpaghettiDetectorRuntime:
|
|
"""Manage one camera/detector target."""
|
|
|
|
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
|
self.hass = hass
|
|
self.entry = entry
|
|
self.data = {**entry.data, **entry.options}
|
|
self.detector_id: str = self.data[CONF_INSTANCE_ID]
|
|
self.name = entry.title
|
|
self.listeners: list[Callable[[], None]] = []
|
|
self.unsubscribers: list[CALLBACK_TYPE] = []
|
|
|
|
self.enabled = True
|
|
self.running = False
|
|
self.status = "idle"
|
|
self.printer_state: str | None = None
|
|
self.confidence = 0.0
|
|
self.raw_score = 0.0
|
|
self.detection_count = 0
|
|
self.detected = False
|
|
self.warning = False
|
|
self.last_run: datetime | None = None
|
|
self.last_detected: datetime | None = None
|
|
self.next_run: datetime | None = None
|
|
self.last_error: str | None = None
|
|
self.last_image_url: str | None = None
|
|
self.last_result: dict[str, Any] = {"detections": []}
|
|
self.lifetime_frames = 0
|
|
self.detected_event_sent_for_active_period = False
|
|
|
|
@property
|
|
def active_states(self) -> set[str]:
|
|
"""Return states that mean the printer is actively printing."""
|
|
return _parse_states(self.data.get(CONF_ACTIVE_PRINT_STATES))
|
|
|
|
@property
|
|
def warning_threshold(self) -> float:
|
|
"""Return warning threshold for this detector."""
|
|
sensitivity = self.data.get(CONF_SENSITIVITY, DEFAULT_SENSITIVITY)
|
|
default_warning, _ = SENSITIVITY_THRESHOLDS.get(
|
|
sensitivity,
|
|
SENSITIVITY_THRESHOLDS[DEFAULT_SENSITIVITY],
|
|
)
|
|
if sensitivity != "custom":
|
|
return float(default_warning)
|
|
return float(self.data.get(CONF_WARNING_THRESHOLD, default_warning))
|
|
|
|
@property
|
|
def failure_threshold(self) -> float:
|
|
"""Return failure threshold for this detector."""
|
|
sensitivity = self.data.get(CONF_SENSITIVITY, DEFAULT_SENSITIVITY)
|
|
_, default_failure = SENSITIVITY_THRESHOLDS.get(
|
|
sensitivity,
|
|
SENSITIVITY_THRESHOLDS[DEFAULT_SENSITIVITY],
|
|
)
|
|
if sensitivity != "custom":
|
|
return float(default_failure)
|
|
return float(self.data.get(CONF_FAILURE_THRESHOLD, default_failure))
|
|
|
|
@property
|
|
def cooldown(self) -> timedelta:
|
|
"""Return notification/action cooldown."""
|
|
return timedelta(
|
|
seconds=int(self.data.get(CONF_COOLDOWN_SECONDS, DEFAULT_COOLDOWN_SECONDS))
|
|
)
|
|
|
|
@property
|
|
def detection_interval(self) -> timedelta:
|
|
"""Return scheduled detection interval."""
|
|
return timedelta(
|
|
seconds=int(
|
|
self.data.get(CONF_DETECTION_INTERVAL, DEFAULT_DETECTION_INTERVAL)
|
|
)
|
|
)
|
|
|
|
@property
|
|
def light_control_mode(self) -> str:
|
|
"""Return how the detector should manage the configured light."""
|
|
mode = self.data.get(CONF_LIGHT_CONTROL_MODE, DEFAULT_LIGHT_CONTROL_MODE)
|
|
if mode in {LIGHT_CONTROL_OFF, LIGHT_CONTROL_LEAVE_ON, LIGHT_CONTROL_RESTORE}:
|
|
return mode
|
|
return DEFAULT_LIGHT_CONTROL_MODE
|
|
|
|
@property
|
|
def light_settle_seconds(self) -> int:
|
|
"""Return seconds to wait after turning on a light before snapshot."""
|
|
try:
|
|
seconds = int(
|
|
float(
|
|
self.data.get(
|
|
CONF_LIGHT_SETTLE_SECONDS,
|
|
DEFAULT_LIGHT_SETTLE_SECONDS,
|
|
)
|
|
)
|
|
)
|
|
except (TypeError, ValueError):
|
|
seconds = DEFAULT_LIGHT_SETTLE_SECONDS
|
|
return max(0, seconds)
|
|
|
|
async def async_setup(self) -> None:
|
|
"""Start scheduled detection."""
|
|
interval = self.detection_interval
|
|
self.next_run = dt_util.utcnow() + interval
|
|
self.unsubscribers.append(
|
|
async_track_time_interval(
|
|
self.hass,
|
|
self._async_interval_update,
|
|
interval,
|
|
)
|
|
)
|
|
|
|
if status_entity := self.data.get(CONF_PRINT_STATUS_SENSOR):
|
|
status_entities = [status_entity]
|
|
if guard_entity := self._inferred_guard_entity(status_entity):
|
|
status_entities.append(guard_entity)
|
|
self.unsubscribers.append(
|
|
async_track_state_change_event(
|
|
self.hass,
|
|
status_entities,
|
|
self._async_status_changed,
|
|
)
|
|
)
|
|
|
|
async def async_unload(self) -> None:
|
|
"""Stop scheduled detection."""
|
|
for unsubscribe in self.unsubscribers:
|
|
unsubscribe()
|
|
self.unsubscribers.clear()
|
|
self.listeners.clear()
|
|
|
|
@callback
|
|
def async_add_listener(self, listener: Callable[[], None]) -> CALLBACK_TYPE:
|
|
"""Add a listener for runtime state changes."""
|
|
self.listeners.append(listener)
|
|
|
|
@callback
|
|
def remove_listener() -> None:
|
|
self.listeners.remove(listener)
|
|
|
|
return remove_listener
|
|
|
|
@callback
|
|
def _notify_listeners(self) -> None:
|
|
"""Notify entities that runtime state changed."""
|
|
for listener in list(self.listeners):
|
|
listener()
|
|
|
|
async def _async_interval_update(self, now: datetime) -> None:
|
|
"""Run detection on interval if the target is active."""
|
|
self.next_run = now + self.detection_interval
|
|
self._notify_listeners()
|
|
if self.enabled and self._should_run_scheduled():
|
|
await self.async_run_detection(manual=False)
|
|
|
|
@callback
|
|
def _async_status_changed(self, event) -> None:
|
|
"""Reset state when a new print starts."""
|
|
old_state = event.data.get("old_state")
|
|
new_state = event.data.get("new_state")
|
|
if new_state is None:
|
|
return
|
|
was_active = (
|
|
old_state is not None
|
|
and _normalize_state(old_state.state) in self.active_states
|
|
)
|
|
is_active = _normalize_state(new_state.state) in self.active_states
|
|
if was_active and not is_active:
|
|
self.detected_event_sent_for_active_period = False
|
|
if is_active and not was_active:
|
|
self.reset()
|
|
|
|
def _should_run_scheduled(self) -> bool:
|
|
"""Return if scheduled detection should run."""
|
|
status_entity = self.data.get(CONF_PRINT_STATUS_SENSOR)
|
|
if not status_entity:
|
|
self.printer_state = None
|
|
if bool(self.data.get(CONF_RUN_WITHOUT_PRINTING, False)):
|
|
return True
|
|
self.status = "waiting_for_print"
|
|
self._notify_listeners()
|
|
return False
|
|
|
|
state = self.hass.states.get(status_entity)
|
|
if state is None:
|
|
self.status = "status_unavailable"
|
|
self.printer_state = None
|
|
self._notify_listeners()
|
|
return False
|
|
|
|
self.printer_state = str(state.state)
|
|
normalized_state = _normalize_state(state.state)
|
|
if normalized_state not in self.active_states:
|
|
self.status = (
|
|
"status_unavailable"
|
|
if normalized_state in {"unknown", "unavailable"}
|
|
else "waiting_for_print"
|
|
)
|
|
self._notify_listeners()
|
|
return False
|
|
|
|
if not self._passes_inferred_guard_sensor(status_entity):
|
|
if self.status != "status_unavailable":
|
|
self.status = "waiting_for_print"
|
|
self._notify_listeners()
|
|
return False
|
|
|
|
self._notify_listeners()
|
|
return True
|
|
|
|
def _passes_inferred_guard_sensor(self, status_entity: str) -> bool:
|
|
"""Return false when an inferred companion status says not active."""
|
|
guard_entity = self._inferred_guard_entity(status_entity)
|
|
if guard_entity is None:
|
|
return True
|
|
|
|
state = self.hass.states.get(guard_entity)
|
|
if state is None:
|
|
return True
|
|
|
|
self.printer_state = f"{self.printer_state}; {guard_entity}={state.state}"
|
|
normalized_state = _normalize_state(state.state)
|
|
if normalized_state in {"unknown", "unavailable"}:
|
|
self.status = "status_unavailable"
|
|
return False
|
|
return normalized_state in self.active_states
|
|
|
|
def _inferred_guard_entity(self, status_entity: str) -> str | None:
|
|
"""Infer an Elegoo companion current-status sensor when available."""
|
|
suffix = "_print_status"
|
|
if not status_entity.endswith(suffix):
|
|
return None
|
|
candidate = f"{status_entity[: -len(suffix)]}_current_status"
|
|
if candidate == status_entity:
|
|
return None
|
|
return candidate
|
|
|
|
def reset(self) -> None:
|
|
"""Reset detection state."""
|
|
self.status = "idle"
|
|
self.confidence = 0.0
|
|
self.raw_score = 0.0
|
|
self.detection_count = 0
|
|
self.detected = False
|
|
self.warning = False
|
|
self.last_detected = None
|
|
self.last_error = None
|
|
self.last_result = {"detections": []}
|
|
self.lifetime_frames = 0
|
|
self.detected_event_sent_for_active_period = False
|
|
self._notify_listeners()
|
|
|
|
async def async_run_detection(self, *, manual: bool) -> dict[str, Any]:
|
|
"""Run one detection request."""
|
|
if self.running:
|
|
self.status = "busy"
|
|
self._notify_listeners()
|
|
return self._service_result()
|
|
|
|
if not manual and not self._should_run_scheduled():
|
|
return self._service_result()
|
|
|
|
self.running = True
|
|
self.status = "checking"
|
|
self.last_run = dt_util.utcnow()
|
|
self.last_error = None
|
|
self._notify_listeners()
|
|
|
|
restore_light: str | None = None
|
|
try:
|
|
restore_light = await self._async_prepare_light()
|
|
if not manual and not self._should_run_scheduled():
|
|
return self._service_result()
|
|
|
|
image_url = self._build_image_url()
|
|
if not image_url:
|
|
self._set_error("camera_image_unavailable")
|
|
return self._service_result()
|
|
|
|
self.last_image_url = image_url
|
|
|
|
try:
|
|
ml_lock = self.hass.data[DOMAIN][RUNTIME_ML_LOCK]
|
|
async with ml_lock:
|
|
result = await self._async_predict(image_url)
|
|
except (aiohttp.ClientError, TimeoutError) as err:
|
|
self._set_error(str(err))
|
|
LOGGER.warning(
|
|
"Obico ML request failed for %s: %s",
|
|
self.detector_id,
|
|
err,
|
|
)
|
|
return self._service_result()
|
|
|
|
self.last_result = result
|
|
self.raw_score, self.detection_count = _score_detections(result)
|
|
self.confidence = self.raw_score
|
|
self.warning = self.confidence >= self.warning_threshold
|
|
self.detected = self.confidence >= self.failure_threshold
|
|
self.status = (
|
|
"detected" if self.detected else "warning" if self.warning else "clear"
|
|
)
|
|
self.lifetime_frames += 1
|
|
|
|
self._fire_result_event(manual)
|
|
if self.detected and self._can_fire_detected_event(manual):
|
|
self.last_detected = dt_util.utcnow()
|
|
if not manual and self.data.get(CONF_PRINT_STATUS_SENSOR):
|
|
self.detected_event_sent_for_active_period = True
|
|
self._fire_detected_event(manual)
|
|
|
|
self._notify_listeners()
|
|
return self._service_result()
|
|
finally:
|
|
if restore_light is not None:
|
|
await self._async_restore_light(restore_light)
|
|
self.running = False
|
|
|
|
async def _async_predict(self, image_url: str) -> dict[str, Any]:
|
|
"""Call the Obico ML API."""
|
|
session = async_get_clientsession(self.hass)
|
|
async with session.get(
|
|
f"{self.data[CONF_OBICO_HOST].rstrip('/')}/p/",
|
|
params={"img": image_url},
|
|
headers={"Authorization": f"Bearer {self.data[CONF_OBICO_AUTH_TOKEN]}"},
|
|
timeout=aiohttp.ClientTimeout(total=60),
|
|
) as response:
|
|
if response.status >= 400:
|
|
error_message = await _response_error_message(response)
|
|
raise aiohttp.ClientResponseError(
|
|
response.request_info,
|
|
response.history,
|
|
status=response.status,
|
|
message=error_message,
|
|
headers=response.headers,
|
|
)
|
|
response.raise_for_status()
|
|
result = await response.json()
|
|
if not isinstance(result, dict):
|
|
return {"detections": []}
|
|
return result
|
|
|
|
async def _async_prepare_light(self) -> str | None:
|
|
"""Prepare the configured light before detection.
|
|
|
|
Returns the entity ID to restore when the integration turned an off
|
|
light on and the selected mode wants the previous state restored.
|
|
"""
|
|
if self.light_control_mode == LIGHT_CONTROL_OFF:
|
|
return None
|
|
|
|
light_entity = self.data.get(CONF_CHAMBER_LIGHT)
|
|
if not light_entity:
|
|
return None
|
|
state = self.hass.states.get(light_entity)
|
|
if state is None or _normalize_state(state.state) != "off":
|
|
return None
|
|
|
|
try:
|
|
await self.hass.services.async_call(
|
|
"light",
|
|
"turn_on",
|
|
{"entity_id": light_entity},
|
|
blocking=True,
|
|
)
|
|
except HomeAssistantError as err:
|
|
LOGGER.warning("Could not turn on light %s: %s", light_entity, err)
|
|
return None
|
|
|
|
restore_light = (
|
|
light_entity if self.light_control_mode == LIGHT_CONTROL_RESTORE else None
|
|
)
|
|
|
|
try:
|
|
if self.light_settle_seconds:
|
|
await asyncio.sleep(self.light_settle_seconds)
|
|
except asyncio.CancelledError:
|
|
if restore_light is not None:
|
|
await self._async_restore_light(restore_light)
|
|
raise
|
|
|
|
return restore_light
|
|
|
|
async def _async_restore_light(self, light_entity: str) -> None:
|
|
"""Restore a light that the detector temporarily turned on."""
|
|
state = self.hass.states.get(light_entity)
|
|
if state is not None and _normalize_state(state.state) == "off":
|
|
return
|
|
|
|
try:
|
|
await self.hass.services.async_call(
|
|
"light",
|
|
"turn_off",
|
|
{"entity_id": light_entity},
|
|
blocking=True,
|
|
)
|
|
except HomeAssistantError as err:
|
|
LOGGER.warning("Could not restore light %s: %s", light_entity, err)
|
|
|
|
def _build_image_url(self) -> str | None:
|
|
"""Build a snapshot URL for the configured camera."""
|
|
if snapshot_url := self.data.get(CONF_SNAPSHOT_URL):
|
|
return snapshot_url
|
|
|
|
camera_entity = self.data.get(CONF_CAMERA)
|
|
state = self.hass.states.get(camera_entity)
|
|
if state is None:
|
|
return None
|
|
entity_picture = state.attributes.get("entity_picture")
|
|
if not entity_picture:
|
|
return None
|
|
return f"{self.data[CONF_HOME_ASSISTANT_HOST].rstrip('/')}{entity_picture}"
|
|
|
|
def _cooldown_elapsed(self) -> bool:
|
|
"""Return whether a detected event can be fired."""
|
|
if self.last_detected is None:
|
|
return True
|
|
return dt_util.utcnow() - self.last_detected >= self.cooldown
|
|
|
|
def _can_fire_detected_event(self, manual: bool) -> bool:
|
|
"""Return whether the detected event should be emitted."""
|
|
if not manual and self.data.get(CONF_PRINT_STATUS_SENSOR):
|
|
return not self.detected_event_sent_for_active_period
|
|
return self._cooldown_elapsed()
|
|
|
|
def _event_data(self, manual: bool) -> dict[str, Any]:
|
|
"""Return event payload."""
|
|
return {
|
|
"config_entry": self.entry.entry_id,
|
|
"detector": self.detector_id,
|
|
"name": self.name,
|
|
"camera": self.data.get(CONF_CAMERA),
|
|
"manual": manual,
|
|
"printer_state": self.printer_state,
|
|
ATTR_CONFIDENCE: self.confidence,
|
|
ATTR_RAW_SCORE: self.raw_score,
|
|
ATTR_DETECTED: self.detected,
|
|
ATTR_DETECTIONS: self.detection_count,
|
|
ATTR_IMAGE_URL: self.last_image_url,
|
|
ATTR_LAST_ERROR: self.last_error,
|
|
ATTR_LAST_RUN: self.last_run.isoformat() if self.last_run else None,
|
|
ATTR_NEXT_RUN: self.next_run.isoformat() if self.next_run else None,
|
|
ATTR_STATUS: self.status,
|
|
}
|
|
|
|
def _fire_result_event(self, manual: bool) -> None:
|
|
"""Fire an event for every detection result."""
|
|
self.hass.bus.async_fire(EVENT_DETECTION_RESULT, self._event_data(manual))
|
|
|
|
def _fire_detected_event(self, manual: bool) -> None:
|
|
"""Fire an event when spaghetti is detected."""
|
|
self.hass.bus.async_fire(EVENT_SPAGHETTI_DETECTED, self._event_data(manual))
|
|
|
|
def _service_result(self) -> dict[str, Any]:
|
|
"""Return service response payload."""
|
|
return {
|
|
"result": self.last_result,
|
|
ATTR_CONFIDENCE: self.confidence,
|
|
ATTR_RAW_SCORE: self.raw_score,
|
|
ATTR_DETECTED: self.detected,
|
|
ATTR_DETECTIONS: self.detection_count,
|
|
ATTR_IMAGE_URL: self.last_image_url,
|
|
ATTR_LAST_ERROR: self.last_error,
|
|
ATTR_NEXT_RUN: self.next_run.isoformat() if self.next_run else None,
|
|
ATTR_STATUS: self.status,
|
|
}
|
|
|
|
def _set_error(self, error: str) -> None:
|
|
"""Set a runtime error and notify listeners."""
|
|
self.status = "error"
|
|
self.last_error = error
|
|
self.detected = False
|
|
self.warning = False
|
|
self.confidence = 0.0
|
|
self.raw_score = 0.0
|
|
self.detection_count = 0
|
|
self._notify_listeners()
|
|
|
|
|
|
async def _response_error_message(response: aiohttp.ClientResponse) -> str:
|
|
"""Return a useful error message from an ML server error response."""
|
|
try:
|
|
payload = await response.json()
|
|
except (aiohttp.ContentTypeError, ValueError):
|
|
return await response.text()
|
|
if not isinstance(payload, dict):
|
|
return str(payload)
|
|
if error := payload.get("error"):
|
|
message = payload.get("message")
|
|
return f"{error}: {message}" if message else str(error)
|
|
return str(payload)
|