feat(mqtt): publish the plate-clear gate and add a notification for it (#2525)

When a print reaches a terminal state Bambuddy holds the queue until
someone confirms the build plate is clear. That gate was visible only in
the Web UI: the printer's own MQTT push reports nothing beyond RUNNING,
PAUSE, FAILED, FINISH and IDLE, so an external automation could not tell
"finished" from "finished and still waiting for a human".

The per-printer status topic now carries an awaiting_plate_clear field,
and every transition is additionally published on a new retained topic,
bambuddy/printers/{serial}/plate_clear. Retained, and published from the
flag itself rather than from printer telemetry: a subscriber learns the
state of every printer the moment it connects, and the state stays
correct after Auto Off powers a printer down - telemetry stops there,
which would otherwise leave the status topic frozen at false.

Publishing is edge-triggered. The queue clears the gate on every
dispatch whether or not it was up, and no subscriber should see a
"plate cleared" for a plate that was never dirty. Persistence and the
WebSocket broadcast stay unconditional; they are idempotent and predate
this.

A matching Plate Clear Required notification event was added, off by
default on every provider because it fires after every print at the
same moment as the print-complete alert. Only the rising edge notifies.
Acknowledging still goes through POST /printers/{id}/clear-plate.

Two tests in test_printer_manager_status_broadcast.py asserted
_schedule_async.call_count == 2 for the setter. The new emission makes
it three on a transition, so they now assert that the persist and
broadcast coroutines are actually scheduled - which is the contract

Translated in all locales; wiki updated. Covered by backend and
frontend tests.
This commit is contained in:
maziggy
2026-07-28 13:36:55 +02:00
parent f4f76e0121
commit 8fd1f884dc
31 changed files with 660 additions and 20 deletions
@@ -43,6 +43,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
const [onMaintenanceDue, setOnMaintenanceDue] = useState(provider?.on_maintenance_due ?? false);
const [onStockReorderAlert, setOnStockReorderAlert] = useState(provider?.on_stock_reorder_alert ?? false);
const [onStockBreakAlert, setOnStockBreakAlert] = useState(provider?.on_stock_break_alert ?? false);
const [onPlateClearRequired, setOnPlateClearRequired] = useState(provider?.on_plate_clear_required ?? false);
const [onBedCooled, setOnBedCooled] = useState(provider?.on_bed_cooled ?? false);
const [onFirstLayerComplete, setOnFirstLayerComplete] = useState(provider?.on_first_layer_complete ?? false);
@@ -188,6 +189,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
on_maintenance_due: onMaintenanceDue,
on_stock_reorder_alert: onStockReorderAlert,
on_stock_break_alert: onStockBreakAlert,
on_plate_clear_required: onPlateClearRequired,
on_bed_cooled: onBedCooled,
on_first_layer_complete: onFirstLayerComplete,
};
@@ -579,6 +581,13 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
</div>
<Toggle checked={onPrintProgress} onChange={setOnPrintProgress} />
</div>
<div className="flex items-center justify-between col-span-2">
<div>
<span className="text-sm text-white">{t('notifications.plateClearRequired')}</span>
<span className="text-xs text-bambu-gray ml-1">{t('notifications.plateClearRequiredDescription')}</span>
</div>
<Toggle checked={onPlateClearRequired} onChange={setOnPlateClearRequired} />
</div>
<div className="flex items-center justify-between col-span-2">
<div>
<span className="text-sm text-white">{t('notifications.bedCooled')}</span>
@@ -652,6 +661,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
if (onPrintFailed) enabledEvents.push({ key: 'on_print_failed', label: t('notifications.failed') });
if (onPrintStopped) enabledEvents.push({ key: 'on_print_stopped', label: t('notifications.stopped') });
if (onPrintProgress) enabledEvents.push({ key: 'on_print_progress', label: t('notifications.progress') });
if (onPlateClearRequired) enabledEvents.push({ key: 'on_plate_clear_required', label: t('notifications.plateClearRequired') });
if (onBedCooled) enabledEvents.push({ key: 'on_bed_cooled', label: t('notifications.bedCooled') });
if (onFirstLayerComplete) enabledEvents.push({ key: 'on_first_layer_complete', label: t('notifications.firstLayerCompleteLabel') });
if (onPrinterOffline) enabledEvents.push({ key: 'on_printer_offline', label: t('notifications.offline') });