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
+2
View File
@@ -12,6 +12,8 @@ All notable changes to Bambuddy will be documented in this file.
- **Home Assistant notifications can carry custom data fields (#1441)** — When a notification provider targets an HA notify service (e.g. `notify.mobile_app_myphone`), a new optional **Data (JSON)** field is forwarded as the service call's nested `data` object — the same place HA automations put mobile push options like `priority`, `ttl`, `channel`, and `group`. `ttl: 0` + `priority: high` make Android pushes arrive immediately instead of batched, and `channel` gives printer alerts their own notification channel/sound. The field is JSON (not key=value lines) so numbers stay numbers (`ttl: 0`) and nested options work. Validated on both ends: the UI rejects malformed JSON before saving, and the sender fails loudly with a clear message rather than posting a half-built payload. Only included when configured — the default persistent-notification path is unchanged, as its schema rejects unknown keys. Translated in all locales; wiki updated. Covered by backend and frontend tests.
- **Energy usage now feeds the statistics that previously only knew about filament (#1432)** — Bambuddy has measured per-print energy via an attached smart plug for a while (the plug's lifetime counter is captured at print start and the delta stored with the print), but two stats surfaces ignored it. First, the **Most Expensive** record on the Statistics page ranked prints by filament cost alone, so a cheap-filament print with hours of heated-chamber time could never win; it now ranks by filament + measured energy cost (prints without a smart plug simply compete on filament cost, as before). Second, **Filament Trends** gained an **Energy Over Time** chart — per-day kWh (per-hour for short ranges, per-week for long ones), with the range's total kWh and energy cost in the header. The chart only appears when the selected range actually contains measured energy data, so setups without smart plugs see no change. The `/archives/slim` stats feed now carries each run's `energy_kwh`/`energy_cost`. Translated in all locales. Covered by backend and frontend tests.
- **The plate-clear gate is now visible over MQTT, and can raise a notification (#2525, reporter @daschaefer)** — 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`/`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` (`{"awaiting": true|false, …}`). Retained and published from the flag itself rather than from printer telemetry, so a subscriber learns the current state of every printer the moment it connects — and the state stays correct after Auto Off powers a printer down, which stops telemetry entirely and would otherwise leave the status topic frozen at `false`. Publishing is edge-triggered: the queue re-asserts the flag on every dispatch, and no subscriber should see a "plate cleared" for a plate that was never dirty. 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. Acknowledging still goes through the existing `POST /printers/{id}/clear-plate`. Translated in all locales; wiki updated. Covered by backend tests.
### Fixed
- **Spools added as stock could not be edited or duplicated without assigning a slicer preset, and that preset then overwrote the spool's own manufacturer (#1905, reporter @rocstat1979)** — A spool created via **Quick Add**, a CSV import or an RFID scan has no slicer preset, brand or subtype. Reopening it in **Edit Spool** demanded all three before anything could be saved — so changing the storage location, the cost or the note was impossible — and **Copy Spool** had the same gate with no Quick Add toggle to waive it. Worse, the preset you were forced to pick auto-filled material, brand and subtype from the preset name, silently rewriting a hand-entered manufacturer ("Elegoo" → "Generic") so the spool no longer appeared where it had been filed. **Fix.** Editing and copying now require only what the backend requires — the material; the preset, brand and subtype fields stay fully visible and editable (nothing is hidden the way Quick Add hides it), and the required-field markers no longer advertise a rule that isn't enforced. Selecting a preset now fills only fields that are still empty or that a previously selected preset had filled, so values you (or the saved spool) provided survive; switching between presets still replaces what the earlier one contributed. **Also fixed:** the brand and material dropdowns no longer *filter* themselves down to the brand/material pairs known to the color catalog and slicer presets — a real combination like Elegoo ASA looked impossible to enter because Elegoo was catalogued only for PLA. Both lists now always offer everything known, with the catalog-paired entries ranked first under a **Suggested** heading and the rest under **All**, and a spool's own custom brand or material is always present in its dropdown. The same change applies to the SpoolBuddy write-tag form, which shares these fields. Lastly, the Quick Add layout no longer leaks out of create mode: quick-adding a spool and then opening Edit used to strand the edit form in the reduced layout with no toggle to leave it. Frontend-only. Translated in all locales; wiki updated. Covered by validation and form-interaction tests.
- **The spool PA-Profil (Pressure Advance) picker only ever offered the 0.4mm K-profile, hiding nozzle-specific profiles for the same filament on multi-nozzle printers (#2618)** — When a printer had two K-profiles for one filament differing only in nozzle size (e.g. PAHT-CF at 0.4mm K=0.042 and 0.6mm K=0.028), the **Edit Spool → PA-Profil** tab (and the SpoolBuddy write-tag page, which shares the picker) showed only the 0.4mm entry ("1 match, K=0.042"), regardless of the nozzle actually installed. **Root cause.** Both surfaces fetched a printer's calibrations with `getKProfiles(printer.id)`, which defaults the nozzle filter to `0.4` — and the printer/MQTT layer filters strictly by that diameter, so the 0.6mm profile was never retrieved. (The AMS-Slot config dialog was already fixed for this in #1899; these two pickers were not.) **Fix.** The picker now queries every nozzle the printer reports installed (`0.4`, `0.6`, …) and merges the results, falling back to `0.4` only when the printer hasn't reported its nozzle hardware. Each profile row now also shows a nozzle-diameter badge so two identically-named profiles are distinguishable. Frontend-only. Covered by tests for the nozzle enumeration and the two-profile rendering.
+2
View File
@@ -58,6 +58,7 @@ def _provider_to_dict(provider: NotificationProvider) -> dict:
"on_ams_ht_temperature_high": provider.on_ams_ht_temperature_high,
# Build plate detection
"on_plate_not_empty": provider.on_plate_not_empty,
"on_plate_clear_required": provider.on_plate_clear_required,
# Bed cooled
"on_bed_cooled": provider.on_bed_cooled,
# First layer complete
@@ -139,6 +140,7 @@ async def create_notification_provider(
on_ams_ht_temperature_high=provider_data.on_ams_ht_temperature_high,
# Build plate detection
on_plate_not_empty=provider_data.on_plate_not_empty,
on_plate_clear_required=provider_data.on_plate_clear_required,
# Bed cooled
on_bed_cooled=provider_data.on_bed_cooled,
# First layer complete
+12
View File
@@ -3798,6 +3798,18 @@ async def run_migrations(conn):
await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN library_file_id INTEGER")
await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN target_sets INTEGER")
# Migration: plate-clear-required notification opt-in (#2525). Off by
# default — it fires after every print, at the same moment as the
# print-complete alert. Postgres rejects `DEFAULT 0` for BOOLEAN.
if is_sqlite():
await _safe_execute(
conn, "ALTER TABLE notification_providers ADD COLUMN on_plate_clear_required BOOLEAN DEFAULT 0"
)
else:
await _safe_execute(
conn, "ALTER TABLE notification_providers ADD COLUMN on_plate_clear_required BOOLEAN DEFAULT false"
)
_USER_PRINT_TEMPLATE_RENAMES: tuple[tuple[str, str, str], ...] = (
("user_print_start", "User Print Started", "User Print Started Email"),
+7 -1
View File
@@ -1242,7 +1242,13 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
try:
printer_info = printer_manager.get_printer(printer_id)
if printer_info:
await mqtt_relay.on_printer_status(printer_id, state, printer_info.name, printer_info.serial_number)
await mqtt_relay.on_printer_status(
printer_id,
state,
printer_info.name,
printer_info.serial_number,
printer_manager.is_awaiting_plate_clear(printer_id),
)
except Exception:
pass # Don't fail status callback if MQTT fails
+2
View File
@@ -84,6 +84,8 @@ class NotificationProvider(Base):
# Event triggers - Build plate detection
on_plate_not_empty = Column(Boolean, default=True) # Objects detected on plate before print
# Off by default: fires after every print, alongside the print-complete alert (#2525)
on_plate_clear_required = Column(Boolean, default=False) # Print ended, queue gated until plate is confirmed clear
# Event triggers - Bed cooled after print
on_bed_cooled = Column(Boolean, default=False) # Bed cooled below threshold after print
@@ -85,6 +85,12 @@ DEFAULT_TEMPLATES = [
"title_template": "Plate Not Empty - Print Paused",
"body_template": "{printer}: Objects detected on build plate. Print has been paused. Clear plate and resume.",
},
{
"event_type": "plate_clear_required",
"name": "Plate Clear Required",
"title_template": "Plate Clear Required",
"body_template": "{printer}: print finished. Confirm the build plate is clear before the queue continues.",
},
{
"event_type": "filament_low",
"name": "Filament Low",
+4
View File
@@ -63,6 +63,9 @@ class NotificationProviderBase(BaseModel):
# 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")
@@ -147,6 +150,7 @@ class NotificationProviderUpdate(BaseModel):
# 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
+46 -1
View File
@@ -240,7 +240,14 @@ class MQTTRelayService:
# Printer Events
# =========================================================================
async def on_printer_status(self, printer_id: int, state: Any, printer_name: str, printer_serial: str):
async def on_printer_status(
self,
printer_id: int,
state: Any,
printer_name: str,
printer_serial: str,
awaiting_plate_clear: bool = False,
):
"""Publish printer status change (throttled to 1 update/sec per printer)."""
if not self.enabled or not self.connected:
return
@@ -275,6 +282,13 @@ class MQTTRelayService:
"big_fan1_speed": state.big_fan1_speed,
"big_fan2_speed": state.big_fan2_speed,
"heatbreak_fan_speed": state.heatbreak_fan_speed,
# Bambuddy-side gate, not printer telemetry (#2525). Mirrors what the
# Web UI already receives via printer_state_to_dict, so an external
# automation can tell "finished" from "finished and still waiting for
# someone to clear the bed". Edge changes are also published on
# printers/{serial}/plate_clear — this topic only refreshes when the
# printer pushes telemetry, which stops entirely after Auto Off.
"awaiting_plate_clear": awaiting_plate_clear,
}
self._publish(
@@ -283,6 +297,37 @@ class MQTTRelayService:
retain=True,
)
async def on_plate_clear_state(
self,
printer_id: int,
printer_name: str,
printer_serial: str,
awaiting: bool,
):
"""Publish the plate-clear gate as it flips (#2525).
Retained, unlike the other per-printer event topics, because this is a
*state* an automation needs on subscribe rather than a moment it might
have missed. The status topic carries the same field, but only refreshes
when the printer pushes telemetry — after Auto Off cycles the printer the
retained status payload would sit at ``awaiting_plate_clear: false``
indefinitely while the gate is in fact still up.
"""
if not self.enabled or not self.connected:
return
self._publish(
f"{self.topic_prefix}/printers/{printer_serial}/plate_clear",
{
"printer_id": printer_id,
"printer_name": printer_name,
"printer_serial": printer_serial,
"awaiting": awaiting,
"timestamp": datetime.now(timezone.utc).isoformat(),
},
retain=True,
)
async def on_printer_online(self, printer_id: int, printer_name: str, printer_serial: str):
"""Publish printer came online event."""
if not self.enabled or not self.connected:
@@ -1381,6 +1381,39 @@ class NotificationService:
variables=variables,
)
async def on_plate_clear_required(
self,
printer_id: int,
printer_name: str,
db: AsyncSession,
):
"""Handle plate-clear-required event — a print ended and the queue is gated (#2525).
Distinct from ``on_plate_not_empty``, which is the camera check *before* a
print starts. This one fires on the rising edge of the Bambuddy-side
awaiting-plate-clear flag, i.e. whenever a print reaches a terminal state
and the next queued job can't dispatch until someone confirms the bed is
free. Off by default on every provider: it lands at the same moment as the
print-complete notification, so opting in is a deliberate choice.
"""
providers = await self._get_providers_for_event(db, "on_plate_clear_required", printer_id)
if not providers:
return
variables = {"printer": printer_name}
title, message = await self._build_message_from_template(db, "plate_clear_required", variables)
await self._send_to_providers(
providers,
title,
message,
db,
"plate_clear_required",
printer_id,
printer_name,
variables=variables,
)
async def on_filament_low(
self,
printer_id: int,
+46
View File
@@ -377,6 +377,13 @@ class PrinterManager:
UI without it. Centralised here so every current AND future caller is
covered without each one having to remember to broadcast.
"""
# Callers re-assert the current value routinely (the queue clears the gate
# on every dispatch, whether or not it was up), so the outward-facing
# emissions below are edge-triggered — an MQTT subscriber or a phone
# notification must not see a "plate cleared" for a plate that was never
# dirty. Persistence and the WebSocket broadcast stay unconditional: they
# are idempotent and predate this (#961/#1128).
changed = awaiting != (printer_id in self._awaiting_plate_clear)
if awaiting:
self._awaiting_plate_clear.add(printer_id)
else:
@@ -386,6 +393,45 @@ class PrinterManager:
if self._loop and self._loop.is_running():
self._schedule_async(self._persist_awaiting_plate_clear(printer_id, awaiting))
self._schedule_async(self._broadcast_status_change(printer_id))
if changed:
self._schedule_async(self._emit_plate_clear_change(printer_id, awaiting))
async def _emit_plate_clear_change(self, printer_id: int, awaiting: bool) -> None:
"""Relay a plate-clear gate transition to MQTT and notifications (#2525).
The flag is Bambuddy-side, so nothing about it reaches an external
automation on its own — the printer's own MQTT push knows only
RUNNING/PAUSE/FAILED/FINISH/IDLE. Emitted from here rather than from the
three call sites so every current and future caller is covered, the same
reasoning as the WebSocket broadcast above.
Imports are local: ``mqtt_relay`` and ``notification_service`` both sit
above this module in the dependency order.
"""
printer = self.get_printer(printer_id)
if not printer:
return
try:
from backend.app.services.mqtt_relay import mqtt_relay
await mqtt_relay.on_plate_clear_state(printer_id, printer.name, printer.serial_number, awaiting)
except Exception as e:
logger.warning("Failed to publish plate-clear state for printer %d: %s", printer_id, e)
# Only the rising edge is worth a notification — "the bed is now free"
# is not an action item, and the queue clears the gate by itself.
if not awaiting:
return
try:
from backend.app.core.database import async_session
from backend.app.services.notification_service import notification_service
async with async_session() as db:
await notification_service.on_plate_clear_required(printer_id, printer.name, db)
except Exception as e:
logger.warning("Failed to send plate-clear notification for printer %d: %s", printer_id, e)
async def _broadcast_status_change(self, printer_id: int) -> None:
"""Emit a ``printer_status`` WebSocket update for this printer (#1128).
@@ -0,0 +1,79 @@
"""Integration tests for the plate-clear-required notification (#2525).
The event is opt-in: it fires after every print, at the same moment as the
print-complete alert, so a provider only receives it when the toggle is
explicitly enabled.
"""
from unittest.mock import AsyncMock, patch
import pytest
from httpx import AsyncClient
from backend.app.services.notification_service import notification_service
class TestPlateClearNotificationDispatch:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_sends_to_a_provider_that_opted_in(self, notification_provider_factory, db_session):
await notification_provider_factory(name="Opted In", on_plate_clear_required=True)
send = AsyncMock()
with patch.object(notification_service, "_send_to_providers", send):
await notification_service.on_plate_clear_required(1, "Workshop", db_session)
assert send.await_count == 1
providers = send.await_args.args[0]
assert [p.name for p in providers] == ["Opted In"]
assert send.await_args.args[4] == "plate_clear_required"
# _build_message_from_template folds in app_name/timestamp; the caller's
# own variable is what matters here.
assert send.await_args.kwargs["variables"]["printer"] == "Workshop"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_silent_for_a_provider_that_did_not_opt_in(self, notification_provider_factory, db_session):
await notification_provider_factory(name="Default Off", on_plate_clear_required=False)
send = AsyncMock()
with patch.object(notification_service, "_send_to_providers", send):
await notification_service.on_plate_clear_required(1, "Workshop", db_session)
send.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.integration
async def test_skips_a_provider_scoped_to_a_different_printer(self, notification_provider_factory, db_session):
await notification_provider_factory(name="Other Printer", on_plate_clear_required=True, printer_id=99)
send = AsyncMock()
with patch.object(notification_service, "_send_to_providers", send):
await notification_service.on_plate_clear_required(1, "Workshop", db_session)
send.assert_not_awaited()
class TestPlateClearProviderField:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_defaults_to_off_on_create_and_round_trips_on_update(self, async_client: AsyncClient):
create = await async_client.post(
"/api/v1/notifications/",
json={
"name": "Plate Clear Test",
"provider_type": "ntfy",
"enabled": True,
"config": {"server": "https://ntfy.sh", "topic": "test-topic"},
},
)
assert create.status_code in (200, 201), create.text
provider_id = create.json()["id"]
assert create.json()["on_plate_clear_required"] is False
update = await async_client.patch(
f"/api/v1/notifications/{provider_id}",
json={"on_plate_clear_required": True},
)
assert update.status_code == 200, update.text
assert update.json()["on_plate_clear_required"] is True
@@ -0,0 +1,253 @@
"""Tests for the plate-clear gate reaching MQTT and notifications (#2525).
``awaiting_plate_clear`` is a Bambuddy-side flag (#961) — the printer's own MQTT
push only ever reports RUNNING/PAUSE/FAILED/FINISH/IDLE, so an external
automation had no way to tell "finished" from "finished and still waiting for
someone to clear the bed". It now rides along on the retained per-printer status
topic, gets its own retained topic on every transition, and can raise a
notification on the rising edge.
"""
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from backend.app.services.mqtt_relay import MQTTRelayService
from backend.app.services.printer_manager import PrinterManager
def _relay() -> MQTTRelayService:
relay = MQTTRelayService()
relay.enabled = True
relay.connected = True
relay.client = MagicMock()
return relay
def _state() -> SimpleNamespace:
return SimpleNamespace(
connected=True,
state="FINISH",
progress=100,
remaining_time=0,
layer_num=250,
total_layers=250,
current_print="benchy.gcode.3mf",
subtask_name="benchy",
gcode_file="benchy.gcode",
temperatures={"nozzle": 40, "bed": 30},
wifi_signal="-50dBm",
chamber_light="off",
speed_level=2,
cooling_fan_speed=0,
big_fan1_speed=0,
big_fan2_speed=0,
heatbreak_fan_speed=0,
)
def _published(relay: MQTTRelayService) -> list[tuple[str, dict, bool]]:
"""Decode every publish as (topic, payload, retain)."""
import json
calls = []
for call in relay.client.publish.call_args_list:
topic = call.args[0]
payload = json.loads(call.args[1])
calls.append((topic, payload, call.kwargs.get("retain", False)))
return calls
class TestStatusPayload:
@pytest.mark.asyncio
async def test_status_payload_carries_awaiting_plate_clear(self):
relay = _relay()
await relay.on_printer_status(1, _state(), "X1C", "01P00A000000001", True)
topic, payload, retain = _published(relay)[0]
assert topic == "bambuddy/printers/01P00A000000001/status"
assert payload["awaiting_plate_clear"] is True
assert retain is True
@pytest.mark.asyncio
async def test_status_payload_defaults_to_not_awaiting(self):
relay = _relay()
await relay.on_printer_status(1, _state(), "X1C", "01P00A000000001")
_, payload, _ = _published(relay)[0]
assert payload["awaiting_plate_clear"] is False
# The pre-existing telemetry fields must survive the addition.
assert payload["state"] == "FINISH"
assert payload["progress"] == 100
class TestPlateClearTopic:
@pytest.mark.asyncio
async def test_publishes_retained_state_on_its_own_topic(self):
relay = _relay()
await relay.on_plate_clear_state(3, "P1S", "01S00C000000003", True)
topic, payload, retain = _published(relay)[0]
assert topic == "bambuddy/printers/01S00C000000003/plate_clear"
assert payload["awaiting"] is True
assert payload["printer_id"] == 3
assert payload["printer_name"] == "P1S"
assert payload["printer_serial"] == "01S00C000000003"
# Retained so a subscriber that connects later learns the current state
# instead of waiting for the next transition.
assert retain is True
@pytest.mark.asyncio
async def test_honours_the_configured_topic_prefix(self):
relay = _relay()
relay.topic_prefix = "farm/bambuddy"
await relay.on_plate_clear_state(3, "P1S", "01S00C000000003", False)
topic, payload, _ = _published(relay)[0]
assert topic == "farm/bambuddy/printers/01S00C000000003/plate_clear"
assert payload["awaiting"] is False
@pytest.mark.asyncio
async def test_silent_when_relay_is_disabled(self):
relay = _relay()
relay.enabled = False
await relay.on_plate_clear_state(3, "P1S", "01S00C000000003", True)
relay.client.publish.assert_not_called()
class TestEdgeTriggering:
"""The setter is re-asserted routinely (the queue clears the gate on every
dispatch), so outward-facing emissions must fire on transitions only."""
def _manager(self) -> PrinterManager:
manager = PrinterManager()
loop = MagicMock()
loop.is_running.return_value = True
manager._loop = loop
return manager
def test_emits_on_the_rising_edge(self):
manager = self._manager()
with patch.object(manager, "_schedule_async") as scheduled:
manager.set_awaiting_plate_clear(7, True)
emitted = [c for c in scheduled.call_args_list if "_emit_plate_clear_change" in repr(c.args[0])]
assert len(emitted) == 1
for call in scheduled.call_args_list:
call.args[0].close()
def test_does_not_re_emit_when_already_awaiting(self):
manager = self._manager()
manager._awaiting_plate_clear.add(7)
with patch.object(manager, "_schedule_async") as scheduled:
manager.set_awaiting_plate_clear(7, True)
emitted = [c for c in scheduled.call_args_list if "_emit_plate_clear_change" in repr(c.args[0])]
assert emitted == []
# Persistence and the WebSocket broadcast are idempotent and stay unconditional.
assert len(scheduled.call_args_list) == 2
for call in scheduled.call_args_list:
call.args[0].close()
def test_does_not_emit_when_clearing_a_gate_that_was_never_up(self):
manager = self._manager()
with patch.object(manager, "_schedule_async") as scheduled:
manager.set_awaiting_plate_clear(7, False)
emitted = [c for c in scheduled.call_args_list if "_emit_plate_clear_change" in repr(c.args[0])]
assert emitted == []
for call in scheduled.call_args_list:
call.args[0].close()
def test_emits_on_the_falling_edge(self):
manager = self._manager()
manager._awaiting_plate_clear.add(7)
with patch.object(manager, "_schedule_async") as scheduled:
manager.set_awaiting_plate_clear(7, False)
emitted = [c for c in scheduled.call_args_list if "_emit_plate_clear_change" in repr(c.args[0])]
assert len(emitted) == 1
for call in scheduled.call_args_list:
call.args[0].close()
class TestEmitFanOut:
@pytest.mark.asyncio
async def test_rising_edge_publishes_and_notifies(self):
manager = PrinterManager()
manager._printer_info[7] = SimpleNamespace(name="X1C", serial_number="01P00A000000001")
publish = AsyncMock()
notify = AsyncMock()
with (
patch("backend.app.services.mqtt_relay.mqtt_relay.on_plate_clear_state", publish),
patch(
"backend.app.services.notification_service.notification_service.on_plate_clear_required",
notify,
),
):
await manager._emit_plate_clear_change(7, True)
publish.assert_awaited_once_with(7, "X1C", "01P00A000000001", True)
assert notify.await_count == 1
assert notify.await_args.args[:2] == (7, "X1C")
@pytest.mark.asyncio
async def test_falling_edge_publishes_but_does_not_notify(self):
manager = PrinterManager()
manager._printer_info[7] = SimpleNamespace(name="X1C", serial_number="01P00A000000001")
publish = AsyncMock()
notify = AsyncMock()
with (
patch("backend.app.services.mqtt_relay.mqtt_relay.on_plate_clear_state", publish),
patch(
"backend.app.services.notification_service.notification_service.on_plate_clear_required",
notify,
),
):
await manager._emit_plate_clear_change(7, False)
publish.assert_awaited_once_with(7, "X1C", "01P00A000000001", False)
notify.assert_not_awaited()
@pytest.mark.asyncio
async def test_unknown_printer_is_a_no_op(self):
manager = PrinterManager()
publish = AsyncMock()
with patch("backend.app.services.mqtt_relay.mqtt_relay.on_plate_clear_state", publish):
await manager._emit_plate_clear_change(999, True)
publish.assert_not_awaited()
@pytest.mark.asyncio
async def test_mqtt_failure_does_not_block_the_notification(self):
manager = PrinterManager()
manager._printer_info[7] = SimpleNamespace(name="X1C", serial_number="01P00A000000001")
notify = AsyncMock()
with (
patch(
"backend.app.services.mqtt_relay.mqtt_relay.on_plate_clear_state",
AsyncMock(side_effect=RuntimeError("broker down")),
),
patch(
"backend.app.services.notification_service.notification_service.on_plate_clear_required",
notify,
),
):
await manager._emit_plate_clear_change(7, True)
assert notify.await_count == 1
@@ -105,6 +105,18 @@ def _fake_state(**overrides):
return SimpleNamespace(**base)
def _scheduled_names(mock) -> list[str]:
"""Coroutine names passed to the patched ``_schedule_async``.
Asserting on names rather than a bare call count keeps this file pinned to
#1128's contract (persist + broadcast on every flag mutation) without
breaking every time another emission is hung off the same setter — #2525
added an edge-triggered MQTT/notification relay, which is covered by its
own test module.
"""
return [call.args[0].__qualname__.rsplit(".", 1)[-1] for call in mock.call_args_list]
class TestSchedulingFromSetAwaitingPlateClear:
"""The hook from the public flag-mutation method into the broadcast."""
@@ -119,8 +131,10 @@ class TestSchedulingFromSetAwaitingPlateClear:
with patch.object(manager, "_schedule_async", side_effect=_close_unawaited) as scheduled:
manager.set_awaiting_plate_clear(7, True)
# Two coroutines: persist + broadcast. Order doesn't matter.
assert scheduled.call_count == 2
# Persist + broadcast, in either order.
names = _scheduled_names(scheduled)
assert "_persist_awaiting_plate_clear" in names
assert "_broadcast_status_change" in names
def test_does_not_schedule_when_no_loop_attached(self, manager):
"""Sync unit-test path (no loop attached): nothing must be
@@ -159,8 +173,10 @@ class TestSchedulingFromSetAwaitingPlateClear:
scheduled.reset_mock()
manager.set_awaiting_plate_clear(7, False)
# Each flip = persist + broadcast = 2 calls.
assert scheduled.call_count == 2
# The False flip persists and broadcasts just like the True flip did.
names = _scheduled_names(scheduled)
assert "_persist_awaiting_plate_clear" in names
assert "_broadcast_status_change" in names
class TestBroadcastStatusChange:
@@ -48,6 +48,7 @@ function buildProvider(overrides: Partial<NotificationProvider> = {}): Notificat
on_ams_ht_humidity_high: false,
on_ams_ht_temperature_high: false,
on_plate_not_empty: true,
on_plate_clear_required: false,
on_bed_cooled: false,
on_first_layer_complete: false,
on_queue_job_added: false,
@@ -223,6 +224,76 @@ describe('AddNotificationModal — ntfy Priority (#990)', () => {
});
});
describe('AddNotificationModal — plate clear required (#2525)', () => {
it('renders the toggle off by default', async () => {
render(<AddNotificationModal provider={buildProvider()} onClose={() => undefined} />);
await screen.findByDisplayValue('My ntfy');
const toggle = screen
.getAllByRole('switch')
.find((s) => s.closest('div')?.textContent?.match(/plate clear required/i));
expect(toggle).toBeDefined();
expect(toggle).toHaveAttribute('aria-checked', 'false');
});
it('pre-fills the toggle from the existing provider value', async () => {
render(
<AddNotificationModal
provider={buildProvider({ on_plate_clear_required: true })}
onClose={() => undefined}
/>,
);
await screen.findByDisplayValue('My ntfy');
const toggle = screen
.getAllByRole('switch')
.find((s) => s.closest('div')?.textContent?.match(/plate clear required/i))!;
expect(toggle).toHaveAttribute('aria-checked', 'true');
});
it('persists on_plate_clear_required on save', async () => {
let captured: unknown = null;
server.use(
http.patch('*/api/v1/notifications/1', async ({ request }) => {
captured = await request.json();
return HttpResponse.json({ id: 1 });
}),
);
const onClose = vi.fn();
const user = userEvent.setup();
render(<AddNotificationModal provider={buildProvider()} onClose={onClose} />);
await screen.findByDisplayValue('My ntfy');
const toggle = screen
.getAllByRole('switch')
.find((s) => s.closest('div')?.textContent?.match(/plate clear required/i))!;
await user.click(toggle);
await user.click(screen.getByRole('button', { name: /^save$/i }));
await waitFor(() => expect(onClose).toHaveBeenCalled());
const payload = captured as Record<string, unknown>;
expect(payload.on_plate_clear_required).toBe(true);
});
it('lists the event in the ntfy priority section once enabled', async () => {
render(
<AddNotificationModal
provider={buildProvider({ on_plate_clear_required: true })}
onClose={() => undefined}
/>,
);
const sectionHeader = await screen.findByText(/ntfy priority/i);
const sectionRoot = sectionHeader.closest('div')!;
expect(within(sectionRoot).getByText(/plate clear required/i)).toBeInTheDocument();
});
});
describe('AddNotificationModal — stock alert toggles', () => {
it('renders Inventory Alerts section with both stock alert toggles', async () => {
render(<AddNotificationModal provider={buildProvider()} onClose={() => undefined} />);
+3
View File
@@ -2493,6 +2493,7 @@ export interface NotificationProvider {
on_ams_ht_temperature_high: boolean;
// Build plate detection
on_plate_not_empty: boolean;
on_plate_clear_required: boolean;
// Bed cooled
on_bed_cooled: boolean;
// First layer complete
@@ -2552,6 +2553,7 @@ export interface NotificationProviderCreate {
on_ams_ht_temperature_high?: boolean;
// Build plate detection
on_plate_not_empty?: boolean;
on_plate_clear_required?: boolean;
// Bed cooled
on_bed_cooled?: boolean;
// First layer complete
@@ -2604,6 +2606,7 @@ export interface NotificationProviderUpdate {
on_ams_ht_temperature_high?: boolean;
// Build plate detection
on_plate_not_empty?: boolean;
on_plate_clear_required?: boolean;
// Bed cooled
on_bed_cooled?: boolean;
// First layer complete
@@ -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') });
@@ -123,6 +123,9 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
{provider.on_print_complete && (
<span className="px-2 py-0.5 bg-bambu-green/20 text-bambu-green text-xs rounded">{t('notifications.complete')}</span>
)}
{provider.on_plate_clear_required && (
<span className="px-2 py-0.5 bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-400 text-xs rounded">{t('notifications.plateClear')}</span>
)}
{provider.on_print_failed && (
<span className="px-2 py-0.5 bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-400 text-xs rounded">{t('notifications.failed')}</span>
)}
@@ -288,6 +291,17 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">{t('notifications.plateClearRequired')}</p>
<p className="text-xs text-bambu-gray">{t('notifications.plateClearRequiredDescription')}</p>
</div>
<Toggle
checked={provider.on_plate_clear_required ?? false}
onChange={(checked) => updateMutation.mutate({ on_plate_clear_required: checked })}
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">{t('notifications.bedCooledLabel')}</p>
+3
View File
@@ -5498,6 +5498,9 @@ export default {
printStarted: 'Druck gestartet',
plateNotEmpty: 'Platte nicht leer',
plateNotEmptyDescription: 'Objekte vor dem Druck erkannt',
plateClearRequired: 'Platte freigeben',
plateClearRequiredDescription: 'Druck beendet, Warteschlange wartet auf Bestätigung',
plateClear: 'Platte freigeben',
printCompleted: 'Druck abgeschlossen',
bedCooledLabel: 'Bett abgekühlt',
bedCooledDescription: 'Bett nach dem Druck unter Schwellenwert abgekühlt',
+3
View File
@@ -5542,6 +5542,9 @@ export default {
printStarted: 'Print Started',
plateNotEmpty: 'Plate Not Empty',
plateNotEmptyDescription: 'Objects detected before print',
plateClearRequired: 'Plate Clear Required',
plateClearRequiredDescription: 'Print finished, queue waits for plate confirmation',
plateClear: 'Plate Clear',
printCompleted: 'Print Completed',
bedCooledLabel: 'Bed Cooled',
bedCooledDescription: 'Bed cooled below threshold after print',
+3
View File
@@ -5507,6 +5507,9 @@ export default {
printStarted: 'Impresión iniciada',
plateNotEmpty: 'Cama no vacía',
plateNotEmptyDescription: 'Objetos detectados antes de la impresión',
plateClearRequired: 'Confirmar cama libre',
plateClearRequiredDescription: 'Impresión terminada, la cola espera confirmación',
plateClear: 'Cama libre',
printCompleted: 'Impresión completada',
bedCooledLabel: 'Cama enfriada',
bedCooledDescription: 'La cama se enfrió por debajo del umbral tras la impresión',
+3
View File
@@ -5488,6 +5488,9 @@ export default {
printStarted: 'Impression démarrée',
plateNotEmpty: 'Plateau non vide',
plateNotEmptyDescription: 'Objets détectés avant l\'impression',
plateClearRequired: 'Plateau à libérer',
plateClearRequiredDescription: 'Impression terminée, la file attend la confirmation',
plateClear: 'Plateau libre',
printCompleted: 'Impression terminée',
bedCooledLabel: 'Plateau refroidi',
bedCooledDescription: 'Plateau refroidi sous le seuil après l\'impression',
+3
View File
@@ -5487,6 +5487,9 @@ export default {
printStarted: 'Stampa avviata',
plateNotEmpty: 'Piatto non vuoto',
plateNotEmptyDescription: 'Oggetti rilevati prima della stampa',
plateClearRequired: 'Conferma piatto libero',
plateClearRequiredDescription: 'Stampa finita, la coda attende conferma',
plateClear: 'Piatto libero',
printCompleted: 'Stampa completata',
bedCooledLabel: 'Piatto raffreddato',
bedCooledDescription: 'Piatto raffreddato sotto la soglia dopo la stampa',
+3
View File
@@ -5499,6 +5499,9 @@ export default {
printStarted: '印刷開始',
plateNotEmpty: 'プレートが空でない',
plateNotEmptyDescription: '印刷前にオブジェクトが検出されました',
plateClearRequired: 'プレート確認が必要',
plateClearRequiredDescription: '印刷完了、キューはプレート確認を待機中',
plateClear: 'プレート確認',
printCompleted: '印刷完了',
bedCooledLabel: 'ベッド冷却済み',
bedCooledDescription: '印刷後にベッドがしきい値以下に冷却',
+3
View File
@@ -5219,6 +5219,9 @@ export default {
printStarted: '인쇄 시작됨',
plateNotEmpty: '플레이트 비어 있지 않음',
plateNotEmptyDescription: '인쇄 전 개체 감지됨',
plateClearRequired: '플레이트 비움 확인 필요',
plateClearRequiredDescription: '인쇄 완료, 대기열이 플레이트 확인을 기다림',
plateClear: '플레이트 확인',
printCompleted: '인쇄 완료됨',
bedCooledLabel: '베드 냉각됨',
bedCooledDescription: '인쇄 후 베드가 임계값 이하로 냉각됨',
+3
View File
@@ -5487,6 +5487,9 @@ export default {
printStarted: 'Impressão Iniciada',
plateNotEmpty: 'Mesa Não Vazia',
plateNotEmptyDescription: 'Objetos detectados antes da impressão',
plateClearRequired: 'Confirmar mesa livre',
plateClearRequiredDescription: 'Impressão concluída, a fila aguarda confirmação',
plateClear: 'Mesa livre',
printCompleted: 'Impressão Concluída',
bedCooledLabel: 'Mesa Resfriada',
bedCooledDescription: 'Mesa resfriou abaixo do limite após a impressão',
+3
View File
@@ -5206,6 +5206,9 @@ export default {
printStarted: "Печать началась",
plateNotEmpty: "Пластина не пуста",
plateNotEmptyDescription: "Перед печатью на пластине обнаружены объекты",
plateClearRequired: "Требуется подтверждение стола",
plateClearRequiredDescription: "Печать завершена, очередь ждет подтверждения",
plateClear: "Стол свободен",
printCompleted: "Печать завершена",
bedCooledLabel: "Стол остыл",
bedCooledDescription: "После печати температура стола опустилась ниже заданного порога",
+3
View File
@@ -5455,6 +5455,9 @@ export default {
printStarted: 'Baskı Başladı',
plateNotEmpty: 'Plaka Boş Değil',
plateNotEmptyDescription: 'Baskıdan önce nesneler algılandı',
plateClearRequired: 'Tabla onayı gerekli',
plateClearRequiredDescription: 'Baskı bitti, kuyruk tabla onayını bekliyor',
plateClear: 'Tabla onayı',
printCompleted: 'Baskı Tamamlandı',
bedCooledLabel: 'Tabla Soğudu',
bedCooledDescription: 'Baskıdan sonra tabla eşiğin altına soğudu',
+3
View File
@@ -5487,6 +5487,9 @@ export default {
printStarted: '打印已开始',
plateNotEmpty: '热床非空',
plateNotEmptyDescription: '打印前检测到物体',
plateClearRequired: '需要确认热床已清空',
plateClearRequiredDescription: '打印完成,队列等待热床确认',
plateClear: '热床确认',
printCompleted: '打印已完成',
bedCooledLabel: '热床已冷却',
bedCooledDescription: '打印后热床温度降至阈值以下',
+3
View File
@@ -5487,6 +5487,9 @@ export default {
printStarted: '列印已開始',
plateNotEmpty: '熱床非空',
plateNotEmptyDescription: '列印前偵測到物體',
plateClearRequired: '需要確認熱床已清空',
plateClearRequiredDescription: '列印完成,佇列等待熱床確認',
plateClear: '熱床確認',
printCompleted: '列印已完成',
bedCooledLabel: '熱床已冷卻',
bedCooledDescription: '列印後熱床溫度降至閾值以下',
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -26,7 +26,7 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-BTdVtpDX.js"></script>
<script type="module" crossorigin src="/assets/index-BNeeHAqi.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Di24iyOw.css">
</head>
<body>