Browse Source

Fix smart_plugs UNIQUE constraint migration not firing on some databases (#689)

  The migration that removes the UNIQUE constraint on smart_plugs.printer_id
  used an exact substring match ("printer_id INTEGER UNIQUE") to detect the
  constraint. Databases created with older SQLAlchemy versions may express
  the constraint differently (quoted column names, table-level UNIQUE clause,
  or separate UNIQUE indexes), causing the migration to silently skip.

  Users hit "IntegrityError: UNIQUE constraint failed: smart_plugs.printer_id"
  when assigning a second HA switch to a printer.

  Replace the exact string match with regex pattern matching that handles
  inline constraints, table-level UNIQUE(printer_id), quoted column names,
  and standalone UNIQUE indexes.
maziggy 2 months ago
parent
commit
81db4eabaf
2 changed files with 24 additions and 2 deletions
  1. 1 0
      CHANGELOG.md
  2. 23 2
      backend/app/core/database.py

+ 1 - 0
CHANGELOG.md

@@ -37,6 +37,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Force Color Match Toggle Click Target Too Large** ([#688](https://github.com/maziggy/bambuddy/issues/688)) — In the Schedule Print modal, clicking anywhere on the "Force color match" row toggled the checkbox, not just the checkbox and its label. The click target now covers only the checkbox, icon, and label text. Reported by @aneopsy.
 - **HA Switch Badge Always Sends Turn On Instead of Toggle** — Clicking a non-script Home Assistant entity (switch, light, input_boolean) on the printer card always sent `turn_on`, which is a no-op when the switch is already on. Now sends `toggle` for non-script entities so the badge click actually toggles the switch state. Script entities still use `turn_on` (stateless trigger).
 - **Multiple Plugs Per Printer Crashes Auto-On/Off** — When multiple smart plugs were assigned to the same printer (e.g., a Tasmota plug + an HA switch), the auto-on/auto-off handler called `scalar_one_or_none()` which raises `MultipleResultsFound`. Now fetches all plugs and returns the main (non-script) power plug, matching the API route behavior.
+- **Multiple HA Switches Per Printer UNIQUE Constraint** — The migration that removes the UNIQUE constraint on `smart_plugs.printer_id` (to allow multiple HA switches per printer) used an exact string match to detect the constraint in the SQLite schema. Databases created with older SQLAlchemy versions expressed the constraint differently (e.g. quoted column names, table-level `UNIQUE(printer_id)`, or separate indexes), so the migration silently skipped them. Users hit `IntegrityError: UNIQUE constraint failed` when assigning a second HA switch to a printer. Now uses regex pattern matching and also checks for standalone UNIQUE indexes.
 - **HMS Notifications for Unknown/Phantom Error Codes** — Printers send many undocumented or phantom HMS error codes that don't correspond to real errors (e.g. calibration status codes after firmware updates). These triggered email/push notifications even though the printer card correctly filtered them out. Flipped the notification logic from "notify all, suppress specific codes" to "only notify for errors with known descriptions", matching the frontend behavior. Also fixed the log message reporting incorrect notification counts.
 - **Debug Logging Endpoint 500 Error** — The `GET /api/v1/support/debug-logging` endpoint returned a 500 Internal Server Error when the database contained a timezone-aware timestamp written by a previous version. The duration calculation subtracted a timezone-aware datetime from a naive `datetime.now()`, raising `TypeError`. Now strips timezone info when reading the stored timestamp.
 - **Bed Cooled Notification Never Fires** ([#497](https://github.com/maziggy/bambuddy/issues/497)) — The bed cooldown monitor always timed out after 30 minutes without sending a notification. After print completion, P1S (and likely other models) sends partial MQTT status updates that don't include `bed_temper`, so the cached bed temperature stayed frozen at the end-of-print value and never dropped below the threshold. The monitor now sends periodic `pushall` commands to the printer to force fresh temperature data. Also added debug logging to the polling loop for future diagnostics.

+ 23 - 2
backend/app/core/database.py

@@ -840,10 +840,31 @@ async def run_migrations(conn):
     # This allows HA scripts to coexist with regular plugs (scripts are for multi-device control)
     # SQLite requires table recreation to drop constraints
     try:
-        # Check if we need to migrate (if UNIQUE constraint exists)
+        # Check if we need to migrate — look for UNIQUE on printer_id in the
+        # CREATE TABLE statement OR as a separate UNIQUE index.
+        needs_migration = False
         result = await conn.execute(text("SELECT sql FROM sqlite_master WHERE type='table' AND name='smart_plugs'"))
         row = result.fetchone()
-        if row and "printer_id INTEGER UNIQUE" in (row[0] or ""):
+        table_sql = (row[0] or "").upper() if row else ""
+        if "PRINTER_ID" in table_sql and "UNIQUE" in table_sql:
+            # Check if UNIQUE appears near printer_id — inline or table-level constraint.
+            # Handle quoted ("PRINTER_ID") and unquoted column names.
+            import re
+
+            if re.search(r'"?PRINTER_ID"?\s+\w+\s+UNIQUE', table_sql) or re.search(
+                r'UNIQUE\s*\([^)]*"?PRINTER_ID"?', table_sql
+            ):
+                needs_migration = True
+        # Also check for separate UNIQUE indexes on printer_id
+        idx_result = await conn.execute(
+            text("SELECT sql FROM sqlite_master WHERE type='index' AND tbl_name='smart_plugs' AND sql IS NOT NULL")
+        )
+        for idx_row in idx_result.fetchall():
+            idx_sql = (idx_row[0] or "").upper()
+            if "UNIQUE" in idx_sql and "PRINTER_ID" in idx_sql:
+                needs_migration = True
+                break
+        if needs_migration:
             # Create new table without UNIQUE constraint on printer_id
             await conn.execute(
                 text("""