mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-08-11 00:30:12 -04:00
An end-of-print auto-off on a plug that powers a filter fan marked the linked printer offline and forced its state to "unknown". The mark was unrecoverable: connected heals on the next MQTT message but state does not (only frames carrying gcode_state rewrite it, and steady-state push_status frames are partial), so the printer stayed "unknown" until a manual Force Refresh and the queue never dispatched to it again. The offline mark is now an explicit presumption: mark_power_off records the state it overwrites and _on_message undoes it as soon as the printer sends another report on its own topic, since inbound traffic proves the power was never cut. A reconnect discards the saved state, so a genuine power cut is unaffected. Each plug also gains a controls_printer_power flag (default true, backfilled) that gates all five power-off paths, and the queue's power-on step now picks the flagged plug instead of whichever linked plug came first.
152 lines
9.4 KiB
Python
152 lines
9.4 KiB
Python
from datetime import datetime
|
|
|
|
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, func
|
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
|
|
from backend.app.core.database import Base
|
|
|
|
|
|
class SmartPlug(Base):
|
|
"""Smart plug for printer power control (Tasmota, Home Assistant, MQTT, or REST)."""
|
|
|
|
__tablename__ = "smart_plugs"
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
name: Mapped[str] = mapped_column(String(100))
|
|
ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True) # IPv4/IPv6 (required for Tasmota)
|
|
|
|
# Plug type: "tasmota" (default), "homeassistant", "mqtt", or "rest"
|
|
plug_type: Mapped[str] = mapped_column(String(20), default="tasmota")
|
|
# Home Assistant entity ID (e.g., "switch.printer_plug")
|
|
ha_entity_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
|
# Home Assistant energy sensor entities (optional, for separate energy sensors)
|
|
ha_power_entity: Mapped[str | None] = mapped_column(String(100), nullable=True) # sensor.xxx_power
|
|
ha_energy_today_entity: Mapped[str | None] = mapped_column(String(100), nullable=True) # sensor.xxx_today
|
|
ha_energy_total_entity: Mapped[str | None] = mapped_column(String(100), nullable=True) # sensor.xxx_total
|
|
|
|
# MQTT plug fields (required when plug_type="mqtt")
|
|
# Legacy field - kept for backward compatibility, now use mqtt_power_topic
|
|
mqtt_topic: Mapped[str | None] = mapped_column(
|
|
String(200), nullable=True
|
|
) # e.g., "zigbee2mqtt/shelly-working-room" (deprecated, use mqtt_power_topic)
|
|
|
|
# Power monitoring
|
|
mqtt_power_topic: Mapped[str | None] = mapped_column(String(200), nullable=True) # Topic for power data
|
|
mqtt_power_path: Mapped[str | None] = mapped_column(String(100), nullable=True) # e.g., "power_l1" or "data.power"
|
|
mqtt_power_multiplier: Mapped[float] = mapped_column(Float, default=1.0) # Unit conversion for power
|
|
|
|
# Energy monitoring
|
|
mqtt_energy_topic: Mapped[str | None] = mapped_column(String(200), nullable=True) # Topic for energy data
|
|
mqtt_energy_path: Mapped[str | None] = mapped_column(String(100), nullable=True) # e.g., "energy_l1"
|
|
mqtt_energy_multiplier: Mapped[float] = mapped_column(Float, default=1.0) # Unit conversion for energy
|
|
|
|
# State monitoring
|
|
mqtt_state_topic: Mapped[str | None] = mapped_column(String(200), nullable=True) # Topic for state data
|
|
mqtt_state_path: Mapped[str | None] = mapped_column(String(100), nullable=True) # e.g., "state_l1" for ON/OFF
|
|
mqtt_state_on_value: Mapped[str | None] = mapped_column(
|
|
String(50), nullable=True
|
|
) # What value means "ON" (e.g., "ON", "true", "1")
|
|
|
|
# Legacy multiplier - kept for backward compatibility
|
|
mqtt_multiplier: Mapped[float] = mapped_column(Float, default=1.0) # Deprecated, use mqtt_power_multiplier
|
|
|
|
# REST/Webhook plug fields (required when plug_type="rest")
|
|
# Control URLs
|
|
rest_on_url: Mapped[str | None] = mapped_column(String(500), nullable=True) # Full URL to turn ON
|
|
rest_on_body: Mapped[str | None] = mapped_column(Text, nullable=True) # Request body for ON
|
|
rest_off_url: Mapped[str | None] = mapped_column(String(500), nullable=True) # Full URL to turn OFF
|
|
rest_off_body: Mapped[str | None] = mapped_column(Text, nullable=True) # Request body for OFF
|
|
rest_method: Mapped[str | None] = mapped_column(String(10), nullable=True) # HTTP method: POST, PUT, GET
|
|
rest_headers: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON string of custom headers
|
|
# Status polling (optional)
|
|
rest_status_url: Mapped[str | None] = mapped_column(String(500), nullable=True) # GET endpoint for state
|
|
rest_status_path: Mapped[str | None] = mapped_column(String(200), nullable=True) # JSON path to state value
|
|
rest_status_on_value: Mapped[str | None] = mapped_column(String(50), nullable=True) # What value means ON
|
|
# Energy monitoring (optional — can use separate URLs or extract from status response)
|
|
rest_power_url: Mapped[str | None] = mapped_column(String(500), nullable=True) # Separate URL for power data
|
|
rest_power_path: Mapped[str | None] = mapped_column(String(200), nullable=True) # JSON path for power (watts)
|
|
rest_power_multiplier: Mapped[float] = mapped_column(Float, server_default="1.0") # Unit conversion for power
|
|
rest_energy_url: Mapped[str | None] = mapped_column(String(500), nullable=True) # Separate URL for energy data
|
|
# Energy used *today*, resetting at midnight (kWh after the multiplier).
|
|
rest_energy_path: Mapped[str | None] = mapped_column(String(200), nullable=True) # JSON path for energy (kWh)
|
|
rest_energy_multiplier: Mapped[float] = mapped_column(
|
|
Float, server_default="1.0"
|
|
) # Unit conversion (e.g., 0.001 for Wh→kWh)
|
|
# Lifetime cumulative counter that never resets (#2539). A Shelly exposes only
|
|
# this one (`aenergy.total`, in Wh); a Tasmota behind a REST bridge exposes
|
|
# both. Kept separate from rest_energy_path because a cumulative counter read
|
|
# as "today" is silently wrong all day, and feeds Yesterday / Total / the
|
|
# hourly snapshots that the Statistics page's date filters run on.
|
|
rest_energy_total_path: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
|
rest_energy_total_multiplier: Mapped[float] = mapped_column(Float, server_default="1.0")
|
|
|
|
# Link to printer (multiple plugs/scripts can be linked to one printer)
|
|
printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id", ondelete="SET NULL"), nullable=True)
|
|
|
|
# Whether this plug actually feeds the printer's own power (#2629). The
|
|
# printer link is also used for accessories that merely follow the print
|
|
# cycle — filter fans, chamber lights, enclosure heaters. Only a plug that
|
|
# really cuts printer power may mark the printer offline on auto-off;
|
|
# doing it for an accessory blanks the printer state and stalls the queue.
|
|
# Defaults to True so existing plugs keep their previous behaviour.
|
|
controls_printer_power: Mapped[bool] = mapped_column(Boolean, default=True, server_default="1")
|
|
|
|
# Automation settings
|
|
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
|
auto_on: Mapped[bool] = mapped_column(Boolean, default=True) # Turn on at print start
|
|
auto_off: Mapped[bool] = mapped_column(Boolean, default=True) # Turn off at print complete/fail
|
|
auto_off_persistent: Mapped[bool] = mapped_column(Boolean, default=False) # Keep auto-off enabled between prints
|
|
|
|
# Turn-off delay mode: "time" or "temperature"
|
|
off_delay_mode: Mapped[str] = mapped_column(String(20), default="time")
|
|
off_delay_minutes: Mapped[int] = mapped_column(Integer, default=5) # For time mode
|
|
off_temp_threshold: Mapped[int] = mapped_column(Integer, default=70) # For temp mode (°C)
|
|
|
|
# Auto-off after AMS drying completes (#1349). Independent of `auto_off`
|
|
# (which only fires after a print finishes). Uses its own delay because
|
|
# the AMS is hot after a drying cycle and users may want longer cooldown
|
|
# than the print-finish default. Fires whenever any AMS attached to the
|
|
# linked printer finishes a dry cycle — Bambuddy doesn't model per-AMS
|
|
# plug routing, the trigger is plug-vs-printer-level.
|
|
auto_off_after_drying: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
|
|
off_delay_after_drying_minutes: Mapped[int] = mapped_column(Integer, default=10, server_default="10")
|
|
|
|
# Optional auth (some Tasmota configs require it)
|
|
username: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
|
password: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
|
|
|
# Power alerts
|
|
power_alert_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
power_alert_high: Mapped[float | None] = mapped_column(Float, nullable=True) # Alert when power > this (watts)
|
|
power_alert_low: Mapped[float | None] = mapped_column(Float, nullable=True) # Alert when power < this (watts)
|
|
power_alert_last_triggered: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) # Cooldown tracking
|
|
|
|
# Schedule (time-based on/off)
|
|
schedule_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
schedule_on_time: Mapped[str | None] = mapped_column(String(5), nullable=True) # "HH:MM" format
|
|
schedule_off_time: Mapped[str | None] = mapped_column(String(5), nullable=True) # "HH:MM" format
|
|
|
|
# Switchbar visibility
|
|
show_in_switchbar: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
|
|
# Printer card visibility (for scripts)
|
|
show_on_printer_card: Mapped[bool] = mapped_column(Boolean, default=True)
|
|
|
|
# Status tracking
|
|
last_state: Mapped[str | None] = mapped_column(String(10), nullable=True) # "ON"/"OFF"
|
|
last_checked: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
|
auto_off_executed: Mapped[bool] = mapped_column(Boolean, default=False) # True when auto-off was triggered
|
|
auto_off_pending: Mapped[bool] = mapped_column(Boolean, default=False) # True when waiting for cooldown
|
|
auto_off_pending_since: Mapped[datetime | None] = mapped_column(
|
|
DateTime, nullable=True
|
|
) # When auto-off was scheduled
|
|
|
|
# Timestamps
|
|
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
|
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
|
|
|
|
# Relationship
|
|
printer: Mapped["Printer"] = relationship(back_populates="smart_plugs")
|
|
|
|
|
|
from backend.app.models.printer import Printer # noqa: E402
|