mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 03:54:17 +02:00
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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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} />);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -5499,6 +5499,9 @@ export default {
|
||||
printStarted: '印刷開始',
|
||||
plateNotEmpty: 'プレートが空でない',
|
||||
plateNotEmptyDescription: '印刷前にオブジェクトが検出されました',
|
||||
plateClearRequired: 'プレート確認が必要',
|
||||
plateClearRequiredDescription: '印刷完了、キューはプレート確認を待機中',
|
||||
plateClear: 'プレート確認',
|
||||
printCompleted: '印刷完了',
|
||||
bedCooledLabel: 'ベッド冷却済み',
|
||||
bedCooledDescription: '印刷後にベッドがしきい値以下に冷却',
|
||||
|
||||
@@ -5219,6 +5219,9 @@ export default {
|
||||
printStarted: '인쇄 시작됨',
|
||||
plateNotEmpty: '플레이트 비어 있지 않음',
|
||||
plateNotEmptyDescription: '인쇄 전 개체 감지됨',
|
||||
plateClearRequired: '플레이트 비움 확인 필요',
|
||||
plateClearRequiredDescription: '인쇄 완료, 대기열이 플레이트 확인을 기다림',
|
||||
plateClear: '플레이트 확인',
|
||||
printCompleted: '인쇄 완료됨',
|
||||
bedCooledLabel: '베드 냉각됨',
|
||||
bedCooledDescription: '인쇄 후 베드가 임계값 이하로 냉각됨',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -5206,6 +5206,9 @@ export default {
|
||||
printStarted: "Печать началась",
|
||||
plateNotEmpty: "Пластина не пуста",
|
||||
plateNotEmptyDescription: "Перед печатью на пластине обнаружены объекты",
|
||||
plateClearRequired: "Требуется подтверждение стола",
|
||||
plateClearRequiredDescription: "Печать завершена, очередь ждет подтверждения",
|
||||
plateClear: "Стол свободен",
|
||||
printCompleted: "Печать завершена",
|
||||
bedCooledLabel: "Стол остыл",
|
||||
bedCooledDescription: "После печати температура стола опустилась ниже заданного порога",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -5487,6 +5487,9 @@ export default {
|
||||
printStarted: '打印已开始',
|
||||
plateNotEmpty: '热床非空',
|
||||
plateNotEmptyDescription: '打印前检测到物体',
|
||||
plateClearRequired: '需要确认热床已清空',
|
||||
plateClearRequiredDescription: '打印完成,队列等待热床确认',
|
||||
plateClear: '热床确认',
|
||||
printCompleted: '打印已完成',
|
||||
bedCooledLabel: '热床已冷却',
|
||||
bedCooledDescription: '打印后热床温度降至阈值以下',
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user