Files
bambuddy/backend/app/schemas/notification.py
T
maziggy 328bac450a Stop auto-drying re-arming into a threshold it can never reach (#2770)
An H2D armed five 12-hour drying cycles inside four hours, one of them six
seconds after the previous one ended, and none ran more than a couple of
hours.

Two things combine. The firmware ends a cycle when it decides the filament
is dry rather than when the clock runs out, and reports no fault doing it --
across this printer's history the run length tracks how wet the spools were,
from nearly the full 12 hours starting at 32% down to minutes once the unit
sat at 10-13%. That part is the AMS doing its job.

The loop is ours. An AMS reports higher relative humidity while it is warm
than once it has cooled: the same unit read 10-13% cold and 15-20% through
every cycle. With the threshold at 14% the reading at the moment a cycle
ended was always still above it, so the next 30-second pass armed another
12-hour cycle. Nothing counted, nothing waited, and it only stopped when the
box finally cooled enough to read 13%.

Auto-drying now waits 30 minutes after a cycle ends before arming another on
the same unit, and gives up on a unit after two consecutive cycles that
bring the reading no lower -- logging why and sending a new notification,
on by default because it reports that Bambuddy has stopped acting. Progress
is judged against the lowest reading any cycle on that unit has ended at,
not against the threshold, so a genuinely wet spool in a humid room coming
down 40-37-35 keeps drying however far it still is from the target;
comparing against the best so far rather than the previous end stops a
sensor wobbling by one point reading as progress every other cycle. The
suspension lifts by itself once the reading falls below the threshold.

Neither guard can stop a running cycle, and a cycle Bambuddy cut short for a
print, or that the user stopped by hand, is not counted against the unit --
so a farm that dries between queue jobs is unaffected. The threshold field
now warns below 20%, and every cycle end logs the unit's temperature and
humidity, which is what made this diagnosable.

The same bundle showed unrelated tasks failing with "database is locked",
each inside a 30.000-second Discord connect timeout. Alarms are raised from
inside the loop that records sensor history, at a point where the new rows
are added but not committed; the first read in the notification path flushed
them to satisfy itself, opening a write transaction, and the provider was
then contacted over the network with that transaction still open. SQLite
allows one writer and 30 seconds outlives the 15-second busy timeout, so
every other write in that window failed. The two reads that run before a
provider is contacted no longer flush the caller's pending work, and the
connect timeout is 5 seconds rather than 30 -- the body keeps the full 30,
so image uploads on a slow uplink are unaffected. SQLite only; Postgres has
no single-writer limit.
2026-08-08 12:40:17 +02:00

309 lines
12 KiB
Python

"""Pydantic schemas for notification providers."""
from datetime import datetime
from typing import Any
from pydantic import BaseModel, Field, field_validator
from backend.app.core.compat import StrEnum
class ProviderType(StrEnum):
"""Supported notification provider types."""
CALLMEBOT = "callmebot"
NTFY = "ntfy"
PUSHOVER = "pushover"
TELEGRAM = "telegram"
EMAIL = "email"
DISCORD = "discord"
WEBHOOK = "webhook"
HOMEASSISTANT = "homeassistant"
BARK = "bark"
class NotificationProviderBase(BaseModel):
"""Base schema for notification providers."""
name: str = Field(..., min_length=1, max_length=100, description="User-defined name")
provider_type: ProviderType = Field(..., description="Type of notification provider")
enabled: bool = Field(default=True, description="Whether notifications are enabled")
config: dict[str, Any] = Field(..., description="Provider-specific configuration")
# Event triggers - print lifecycle
on_print_start: bool = Field(default=False, description="Notify on print start")
on_print_complete: bool = Field(default=True, description="Notify on print complete")
on_print_failed: bool = Field(default=True, description="Notify on print failed")
on_print_stopped: bool = Field(default=True, description="Notify when print is stopped/cancelled")
on_print_progress: bool = Field(default=False, description="Notify at 25%, 50%, 75% progress")
on_print_missing_spool_assignment: bool = Field(
default=False,
description="Notify when a print starts with required trays missing spool assignments",
)
on_billing_charge_failed: bool = Field(default=True, description="Notify when a print charge cannot be recorded")
# Event triggers - printer status
on_printer_offline: bool = Field(default=False, description="Notify when printer goes offline")
on_printer_error: bool = Field(default=False, description="Notify on printer errors (AMS, etc.)")
on_ai_failure_detection: bool = Field(
default=False,
description="Notify when Obico AI detects a possible print failure (spaghetti)",
)
on_filament_low: bool = Field(default=False, description="Notify when filament is running low")
on_maintenance_due: bool = Field(default=False, description="Notify when maintenance is due")
# Event triggers - AMS environmental alarms (regular AMS)
on_ams_humidity_high: bool = Field(default=False, description="Notify when AMS humidity exceeds threshold")
on_ams_temperature_high: bool = Field(default=False, description="Notify when AMS temperature exceeds threshold")
on_ams_drying_suspended: bool = Field(
default=True, description="Notify when automatic drying gives up on an AMS unit"
)
# Event triggers - AMS-HT environmental alarms
on_ams_ht_humidity_high: bool = Field(default=False, description="Notify when AMS-HT humidity exceeds threshold")
on_ams_ht_temperature_high: bool = Field(
default=False, description="Notify when AMS-HT temperature exceeds threshold"
)
# Event triggers - Home Assistant sensors (#1148)
on_ha_sensor_alert: bool = Field(
default=False, description="Notify when a bound Home Assistant sensor enters its alert state"
)
# Event triggers - Build plate detection
on_plate_not_empty: bool = Field(default=True, description="Notify when objects detected on plate before print")
on_plate_clear_required: bool = Field(
default=False, description="Notify when a finished print is waiting for plate-clear confirmation"
)
# Event triggers - Bed cooled
on_bed_cooled: bool = Field(default=False, description="Notify when bed cools after print")
# Event triggers - First layer complete
on_first_layer_complete: bool = Field(default=False, description="Notify when first layer completes")
# Event triggers - Print queue
on_queue_job_added: bool = Field(default=False, description="Notify when job is added to queue")
on_queue_job_assigned: bool = Field(default=False, description="Notify when model-based job is assigned to printer")
on_queue_job_started: bool = Field(default=False, description="Notify when queue job starts printing")
on_queue_job_waiting: bool = Field(default=True, description="Notify when job is waiting for filament or printer")
on_queue_job_skipped: bool = Field(default=True, description="Notify when job is skipped")
on_queue_job_failed: bool = Field(default=True, description="Notify when job fails to start")
on_queue_completed: bool = Field(default=False, description="Notify when all queue jobs finish")
# Quiet hours
quiet_hours_enabled: bool = Field(default=False, description="Enable quiet hours")
quiet_hours_start: str | None = Field(default=None, description="Start time in HH:MM format")
quiet_hours_end: str | None = Field(default=None, description="End time in HH:MM format")
# Daily digest
daily_digest_enabled: bool = Field(default=False, description="Batch notifications into daily digest")
daily_digest_time: str | None = Field(default=None, description="Time to send digest in HH:MM format")
# Printer filter
printer_id: int | None = Field(default=None, description="Specific printer ID or null for all")
@field_validator("quiet_hours_start", "quiet_hours_end", "daily_digest_time")
@classmethod
def validate_time_format(cls, v: str | None) -> str | None:
if v is None:
return v
try:
parts = v.split(":")
if len(parts) != 2:
raise ValueError("Invalid time format")
hour, minute = int(parts[0]), int(parts[1])
if not (0 <= hour <= 23 and 0 <= minute <= 59):
raise ValueError("Invalid time range")
return f"{hour:02d}:{minute:02d}"
except (ValueError, TypeError):
raise ValueError("Time must be in HH:MM format (e.g., 22:00)")
class NotificationProviderCreate(NotificationProviderBase):
"""Schema for creating a notification provider."""
pass
class NotificationProviderUpdate(BaseModel):
"""Schema for updating a notification provider (all fields optional)."""
name: str | None = Field(default=None, min_length=1, max_length=100)
provider_type: ProviderType | None = None
enabled: bool | None = None
config: dict[str, Any] | None = None
# Event triggers - print lifecycle
on_print_start: bool | None = None
on_print_complete: bool | None = None
on_print_failed: bool | None = None
on_print_stopped: bool | None = None
on_print_progress: bool | None = None
on_print_missing_spool_assignment: bool | None = None
on_billing_charge_failed: bool | None = None
# Event triggers - printer status
on_printer_offline: bool | None = None
on_printer_error: bool | None = None
on_ai_failure_detection: bool | None = None
on_filament_low: bool | None = None
on_maintenance_due: bool | None = None
# Event triggers - AMS environmental alarms (regular AMS)
on_ams_humidity_high: bool | None = None
on_ams_temperature_high: bool | None = None
on_ams_drying_suspended: bool | None = None
# Event triggers - AMS-HT environmental alarms
on_ams_ht_humidity_high: bool | None = None
on_ams_ht_temperature_high: bool | None = None
# Event triggers - Home Assistant sensors (#1148)
on_ha_sensor_alert: bool | None = None
# Event triggers - Build plate detection
on_plate_not_empty: bool | None = None
on_plate_clear_required: bool | None = None
# Event triggers - Bed cooled
on_bed_cooled: bool | None = None
# Event triggers - First layer complete
on_first_layer_complete: bool | None = None
# Event triggers - Print queue
on_queue_job_added: bool | None = None
on_queue_job_assigned: bool | None = None
on_queue_job_started: bool | None = None
on_queue_job_waiting: bool | None = None
on_queue_job_skipped: bool | None = None
on_queue_job_failed: bool | None = None
on_queue_completed: bool | None = None
# Quiet hours
quiet_hours_enabled: bool | None = None
quiet_hours_start: str | None = None
quiet_hours_end: str | None = None
# Daily digest
daily_digest_enabled: bool | None = None
daily_digest_time: str | None = None
# Printer filter
printer_id: int | None = None
class NotificationProviderResponse(NotificationProviderBase):
"""Schema for notification provider API responses."""
id: int
last_success: datetime | None = None
last_error: str | None = None
last_error_at: datetime | None = None
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class NotificationTestRequest(BaseModel):
"""Schema for testing notification configuration."""
provider_type: ProviderType
config: dict[str, Any]
class NotificationTestResponse(BaseModel):
"""Schema for test notification response."""
success: bool
message: str
# Provider-specific config schemas for documentation/validation reference
class CallMeBotConfig(BaseModel):
"""CallMeBot/WhatsApp configuration."""
phone: str = Field(..., description="Phone number with country code (e.g., +1234567890)")
apikey: str = Field(..., description="API key from CallMeBot")
class NtfyConfig(BaseModel):
"""ntfy configuration."""
server: str = Field(default="https://ntfy.sh", description="ntfy server URL")
topic: str = Field(..., description="Topic name to publish to")
auth_token: str | None = Field(default=None, description="Optional authentication token")
event_priorities: dict[str, int] | None = Field(
default=None,
description=(
"Per-event priority override. Keys are event names (e.g. 'on_print_failed'); "
"values are ntfy priorities 1-5 (1=min, 2=low, 3=default, 4=high, 5=urgent). "
"Events without an entry use ntfy's server-side default."
),
)
class PushoverConfig(BaseModel):
"""Pushover configuration."""
user_key: str = Field(..., description="Your Pushover user key")
app_token: str = Field(..., description="Your Pushover application token")
priority: int = Field(default=0, ge=-2, le=2, description="Message priority (-2 to 2)")
# Emergency priority (2) only: how often to re-alert and when to stop.
# Pushover requires retry >= 30s and expire <= 10800s (3h).
retry: int = Field(default=60, ge=30, le=10800, description="Emergency re-alert interval in seconds (priority 2)")
expire: int = Field(default=3600, ge=30, le=10800, description="Emergency alert expiry in seconds (priority 2)")
class TelegramConfig(BaseModel):
"""Telegram bot configuration."""
bot_token: str = Field(..., description="Bot token from @BotFather")
chat_id: str = Field(..., description="Chat ID to send messages to")
class EmailConfig(BaseModel):
"""Email/SMTP configuration."""
smtp_server: str = Field(..., description="SMTP server hostname")
smtp_port: int = Field(default=587, description="SMTP port (587 for TLS, 465 for SSL)")
username: str = Field(..., description="SMTP username/email")
password: str = Field(..., description="SMTP password or app password")
from_email: str = Field(..., description="From email address")
to_email: str = Field(..., description="Recipient email address")
use_tls: bool = Field(default=True, description="Use TLS encryption")
# Notification Log schemas
class NotificationLogResponse(BaseModel):
"""Schema for notification log API responses."""
id: int
provider_id: int
provider_name: str | None = None
provider_type: str | None = None
event_type: str
title: str
message: str
success: bool
error_message: str | None = None
printer_id: int | None = None
printer_name: str | None = None
created_at: datetime
class Config:
from_attributes = True
class NotificationLogStats(BaseModel):
"""Statistics for notification logs."""
total: int
success_count: int
failure_count: int
by_event_type: dict[str, int]
by_provider: dict[str, int]