mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-19 07:33:18 +02:00
fix(smart-plugs): don't blank printer state when an accessory plug switches off (#2629)
An end-of-print auto-off on a plug that powers a filter fan marked the linked printer offline and forced its state to "unknown". The mark was unrecoverable: connected heals on the next MQTT message but state does not (only frames carrying gcode_state rewrite it, and steady-state push_status frames are partial), so the printer stayed "unknown" until a manual Force Refresh and the queue never dispatched to it again. The offline mark is now an explicit presumption: mark_power_off records the state it overwrites and _on_message undoes it as soon as the printer sends another report on its own topic, since inbound traffic proves the power was never cut. A reconnect discards the saved state, so a genuine power cut is unaffected. Each plug also gains a controls_printer_power flag (default true, backfilled) that gates all five power-off paths, and the queue's power-on step now picks the flagged plug instead of whichever linked plug came first.
This commit is contained in:
@@ -12,6 +12,7 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
- **Orca Cloud profile sync now connects by approving a code instead of the copy-paste sign-in** — Connecting Bambuddy to Orca Cloud used to mean opening an OAuth sign-in in a new tab, watching it redirect to a `localhost` URL that fails to load, then copying that dead URL out of the address bar and pasting it back into Bambuddy. That dance existed only because Orca's auth backend (Supabase) accepts no redirect target other than `localhost`, and the deliberately-broken redirect page confused nearly everyone who reached it. OrcaSlicer has since shipped a first-class external-app pairing API (the OAuth 2.0 Device Authorization Grant, RFC 8628), so the flow is now: click **Connect**, approve a short code on your Orca Cloud settings page, and Bambuddy pairs itself — no redirect, no paste, no client secret, and it behaves identically from a LAN IP, `localhost`, or behind a reverse proxy. Bambuddy requests **read-only** access (it only lists and views your Orca Cloud profiles), keeps the pairing alive with the API's rotating refresh tokens (validated end-to-end against Orca's staging and production servers), and stores nothing beyond the issued token pair. The profile list and detail views are unchanged, so nothing downstream of the connect step looks different. The old paste-based sign-in and the email/password fallback are removed. Points at production Orca Cloud by default; `ORCA_CLOUD_API_BASE` overrides the endpoint for testing.
|
||||
|
||||
### Fixed
|
||||
- **Switching off an accessory smart plug at the end of a print knocked the printer into "Unknown" and stalled the queue (#2629)** — With an end-of-print auto-off on a plug that powers a *filter fan* (not the printer), the printer flipped to **Unknown** the moment the plug switched off and the queue stopped dispatching to it until a manual **Force Refresh**. The printer itself never went anywhere — MQTT traffic continued a second later. **Root cause, two parts.** (1) Bambuddy treats *any* plug linked to a printer as that printer's power supply: every auto-off — time delay, temperature delay, the time-of-day schedule, a resumed-after-restart off, and a manual off from the plug card — immediately marked the printer offline, whether or not the plug feeds it. (2) That mark was **unrecoverable**. It forces `connected=False` and the state to `unknown`; `connected` heals on the very next MQTT message, but the state does not — the printer state is only rewritten when a status frame carries `gcode_state`, and the steady-state frames a P1S sends are partial. So `unknown` stuck until the next full status push, and the scheduler (which dispatches only to `IDLE`/`FINISH`/`FAILED`) treated the printer as permanently unavailable. **Fix.** The offline mark is now an explicitly *presumed* power cut: the state it overwrites is remembered, and the presumption is undone as soon as the printer sends another report on its own topic, since inbound traffic proves the power was never cut (a frame that does carry `gcode_state` still wins, and the recovery re-broadcasts so the UI un-greys). A printer whose power really was cut sends nothing, so it correctly stays offline — this also repairs the same stuck-`unknown` for a genuine printer plug whose MQTT resumes without a full push. On top of that, each plug now carries a **Powers the printer** toggle (shown when a printer is linked, in both the plug card and the add/edit dialog): leave it on for the plug that feeds the printer, turn it off for accessories — filter fan, chamber light, enclosure heater — and switching those off no longer touches the printer's state at all. Existing plugs are migrated as power plugs, so nothing changes until you say otherwise. The same flag also fixes the queue's power-on step, which used to pick whichever linked plug came first and could spend the whole power-on timeout waiting for a filter fan to boot a printer; it now picks the plug flagged as the power source. Covered by an end-to-end regression test that drives the real MQTT client, printer manager and scheduler through the reported sequence (accessory off → printer keeps talking → queue dispatches again; real power cut → stays offline), MQTT-client tests (presumed off remembers and restores the state, a partial frame recovers it, a real `gcode_state` overrides it, request-topic traffic does not count as proof of life, a reconnect discards the saved state, and a genuine second power cut is not undone), smart-plug tests (accessory plugs switch off without marking the printer offline on all four off-paths, power plugs keep the old behaviour), scheduler tests (power-plug selection), and migration tests on both SQLite and PostgreSQL.
|
||||
- **A2L "AMS Lite" slots showed as empty and never deducted filament** — On an A2L with the 4-slot AMS Lite attached, physically loaded slots could display as empty, filament usage was never deducted from the loaded spool, and linking an AMS-Lite slot to a Spoolman spool failed outright. **Root cause.** The A2L reports its AMS Lite as unit **id 16**, but the firmware is internally inconsistent about it: its slot-presence bitmasks sit at the bit positions for unit **6** (bit base 24), and it reports the actively-feeding slot (`tray_now`) as a **local** 0-3 index rather than a global tray id. Bambuddy's `ams_id*4+slot` convention, fed the raw id 16, probed bit positions 64-67 (always zero) and marked every loaded slot empty; the local `tray_now` was read as a global id, so usage was attributed to the wrong spool (or dropped); and the `ams_id <= 7` database constraint rejected id-16 Spoolman links. **Fix.** The AMS Lite is now normalised from unit **16 → 6** at the MQTT ingest boundary, so its global tray ids land at **24-27** — matching the firmware's own bit base, working with every existing `ams_id*4+slot` consumer (slot presence, usage tracking, deficit warnings, the scheduler, Load/Unload), colliding with nothing, and passing the database constraint. The local `tray_now` is globalised to `24+slot` so deduction hits the right spool, the valid-tray guards accept the 24-27 range, and the printer card labels the unit "AMS Lite". Prints dispatched to the Lite build the correct `ams_mapping2` (`{ams_id:16, slot_id:0-3}`) and flat mapping (local 0-3), both confirmed against the firmware's own mapping. Outbound slot commands (set filament, reset, load/unload, calibration, RFID refresh) translate the normalised id 6 back to the physical 16 on the wire, centralised in one helper. The whole normalisation is self-scoping — only unit id 16 is ever touched, so every other printer and AMS type is byte-for-byte unaffected. Verified against the reporter's live captures (slot presence via `tray_exist_bits`, and `tray_now` while printing a known physical slot); mixed setups (a regular AMS attached alongside the Lite) are out of scope and log a warning, and one uncaptured wire encoding (the physical global tray field on `load`/`calibration` commands) is extrapolated and isolated to the single translation helper.
|
||||
- **Bambu Cloud kept dropping to "sign-in expired" and forcing constant re-logins, even while cloud features still worked** — Since the #2562 status rework, the Bambu Cloud sign-in would flip to "expired" shortly after logging in, over and over, with nothing in the logs to explain it. **Root cause.** The rework made a genuine 401 from Bambu durably record the stored token as dead (`cloud_token_invalid_at`) — correct in principle, but it treated **any** HTTP 401 from **any** cloud or MakerWorld call as a dead token. Bambu returns 401 for plenty of non-fatal reasons (an endpoint-, region- or scope-specific refusal; a Cloudflare-edge blip; a brief backend hiccup), so a single stray 401 from any one call — including a background poll — durably signed the whole cloud integration out until the next manual re-login. Because the flag lives in the database, a setup running more than one Bambuddy instance against the same database made it worse: a stray 401 seen by either instance signed the user out in both. **Fix.** Invalidation now fires only for Bambu's documented token-expiry response — `{"code":4,"error":"Please login."}` — and never for a plain or unparseable 401, which is treated as transient (the request fails, but the session is left signed in). The same signature gate is applied to the MakerWorld path, which shares the token. A genuinely expired token is still detected and surfaced exactly as before; what stops is the false "expired" on a working session. Covered by service tests for both engines: `code:4` and the "Please login." text invalidate, while a benign `code:1`/`forbidden` 401, an unparseable 401, and (for MakerWorld) a signature-less 401 with a token do not.
|
||||
- **Every SpoolBuddy screen crashed the moment a text field was focused (#2616, reporters @MartinNYHC, @agentdr8)** — Tapping the Search box on the SpoolBuddy inventory, or the Search / Color Name / Brand fields on the write-tag New Spool tab, blanked the UI with a minified React error #130 ("Element type is invalid… but got: object"). It hit both internal and Spoolman inventories, so it was not data-specific. **Root cause.** The SpoolBuddy shell mounts an on-screen keyboard (`VirtualKeyboard`) that pops up on `focusin` for any text input — which is why every field on every SpoolBuddy page tripped it, while the main app (no on-screen keyboard) was fine. That component does `import Keyboard from 'react-simple-keyboard'`, a CommonJS package, and under the current bundler's CJS→ESM interop the default import resolves to the module **namespace object** (`{ KeyboardReact, default }`) rather than the component itself. Rendering that object as `<Keyboard>` put an object where an element type belongs, and React threw. (The discrepancy is interop-specific: the test runner hands back the real component, so it only manifested in the browser build — which is why it needed a runtime, not a type, fix.) **Fix.** A small `resolveInteropDefault` helper unwraps such an interop-wrapped default: it returns the value as-is when it's already a usable element type (function/class, tag string, or a `$$typeof`-marked forwardRef/memo/lazy) and otherwise falls through to `.default` and named exports. `VirtualKeyboard` resolves the real `react-simple-keyboard` component through it, so the keyboard renders under any interop shape. Covered by unit tests for the resolver against the object shape, a named-only export, a forwardRef object, and a bare component, plus a render test that mounts the keyboard on input focus.
|
||||
|
||||
@@ -583,8 +583,9 @@ async def control_smart_plug(
|
||||
plug.last_state = expected_state
|
||||
if expected_state == "ON":
|
||||
plug.auto_off_executed = False # Reset flag when manually turning on
|
||||
elif expected_state == "OFF" and plug.printer_id:
|
||||
# Mark printer offline immediately for faster UI update
|
||||
elif expected_state == "OFF" and plug.printer_id and plug.controls_printer_power:
|
||||
# Mark printer offline immediately for faster UI update. Skipped for
|
||||
# accessory plugs, which are linked to a printer but don't feed it (#2629).
|
||||
printer_manager.mark_printer_offline(plug.printer_id)
|
||||
plug.last_checked = utcnow_naive()
|
||||
await db.commit()
|
||||
|
||||
@@ -3751,6 +3751,20 @@ async def run_migrations(conn):
|
||||
# #2603 archive plate_id backfill above so print_archives.plate_id is populated.
|
||||
await _migrate_scope_run_filament_to_plate(conn)
|
||||
|
||||
# Migration: Add controls_printer_power to smart_plugs (#2629). Marks
|
||||
# whether a plug actually feeds the printer's own power — only then may an
|
||||
# auto-off mark the printer offline. Defaults to true so existing plugs
|
||||
# keep the previous behaviour; accessory plugs (filter fan, lights) are
|
||||
# opted out by the user. BOOLEAN literals differ per dialect (SQLite has
|
||||
# no true/false keyword), so the default is dialect-branched.
|
||||
if is_sqlite():
|
||||
await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN controls_printer_power BOOLEAN DEFAULT 1")
|
||||
else:
|
||||
await _safe_execute(
|
||||
conn,
|
||||
"ALTER TABLE smart_plugs ADD COLUMN IF NOT EXISTS controls_printer_power BOOLEAN DEFAULT true",
|
||||
)
|
||||
|
||||
# Migration: Disambiguate the four ``user_print_*`` notification template
|
||||
# names by appending " Email" (#1792). See ``_migrate_rename_user_print_template_names``.
|
||||
await _migrate_rename_user_print_template_names(conn)
|
||||
|
||||
@@ -83,6 +83,14 @@ class SmartPlug(Base):
|
||||
# Link to printer (multiple plugs/scripts can be linked to one printer)
|
||||
printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id", ondelete="SET NULL"), nullable=True)
|
||||
|
||||
# Whether this plug actually feeds the printer's own power (#2629). The
|
||||
# printer link is also used for accessories that merely follow the print
|
||||
# cycle — filter fans, chamber lights, enclosure heaters. Only a plug that
|
||||
# really cuts printer power may mark the printer offline on auto-off;
|
||||
# doing it for an accessory blanks the printer state and stalls the queue.
|
||||
# Defaults to True so existing plugs keep their previous behaviour.
|
||||
controls_printer_power: Mapped[bool] = mapped_column(Boolean, default=True, server_default="1")
|
||||
|
||||
# Automation settings
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
auto_on: Mapped[bool] = mapped_column(Boolean, default=True) # Turn on at print start
|
||||
|
||||
@@ -66,6 +66,10 @@ class SmartPlugBase(BaseModel):
|
||||
rest_energy_total_multiplier: float = Field(default=1.0, ge=0.0001, le=10000)
|
||||
|
||||
printer_id: int | None = None
|
||||
# #2629: only a plug that really feeds the printer may mark it offline when
|
||||
# it switches off. Accessory plugs (filter fan, lights) are linked to a
|
||||
# printer purely to follow the print cycle.
|
||||
controls_printer_power: bool = True
|
||||
enabled: bool = True
|
||||
auto_on: bool = True
|
||||
auto_off: bool = True
|
||||
@@ -160,6 +164,8 @@ class SmartPlugUpdate(BaseModel):
|
||||
rest_energy_total_path: str | None = None
|
||||
rest_energy_total_multiplier: float | None = Field(default=None, ge=0.0001, le=10000)
|
||||
printer_id: int | None = None
|
||||
# #2629: see SmartPlugBase.controls_printer_power.
|
||||
controls_printer_power: bool | None = None
|
||||
enabled: bool | None = None
|
||||
auto_on: bool | None = None
|
||||
auto_off: bool | None = None
|
||||
|
||||
@@ -652,6 +652,11 @@ class BambuMQTTClient:
|
||||
# to once per client lifetime so the stale loop doesn't spam it (#1465).
|
||||
self._report_messages_since_connect: int = 0
|
||||
self._zero_report_hint_logged: bool = False
|
||||
# Set by mark_power_off() to the gcode_state held just before we
|
||||
# optimistically forced the printer to "unknown" (#2629). Restored on
|
||||
# the next inbound message, because message traffic proves the power
|
||||
# was never actually cut. None whenever no power-off is presumed.
|
||||
self._state_before_power_off: str | None = None
|
||||
# Raw-message fan-out for VP MQTT bridge (non-proxy modes republish the
|
||||
# printer's pushes verbatim to slicers connected to a virtual printer).
|
||||
# Handlers receive (topic, payload_bytes) before JSON parsing.
|
||||
@@ -758,6 +763,55 @@ class BambuMQTTClient:
|
||||
time_since_last = time.time() - self._last_message_time
|
||||
return time_since_last > self.STALE_TIMEOUT
|
||||
|
||||
def mark_power_off(self) -> bool:
|
||||
"""Presume the printer lost power (smart plug switched off).
|
||||
|
||||
Optimistic: it skips the MQTT stale timeout so the UI updates at once.
|
||||
The presumption is undone by ``_on_message`` if the printer keeps
|
||||
talking — inbound traffic proves the power was never cut (#2629).
|
||||
Returns True when the state was actually changed.
|
||||
"""
|
||||
if not self.state.connected:
|
||||
return False
|
||||
previous = self.state.state
|
||||
# Blank the state BEFORE recording what to restore. This runs on the
|
||||
# event loop while _on_message runs on the paho thread, and the restore
|
||||
# is a two-step (read saved state, compare against "unknown"). Writing
|
||||
# "unknown" first means an interleaved message either sees no saved
|
||||
# state yet (and skips, leaving the next message to restore) or sees a
|
||||
# consistent pair — never a saved state paired with a live state it
|
||||
# then discards, which would strand the printer on "unknown".
|
||||
self.state.connected = False
|
||||
self.state.state = "unknown"
|
||||
# Only the first mark wins: a second call before any message arrives
|
||||
# must not overwrite the real state with the "unknown" it just wrote.
|
||||
# Nothing to restore if the state was already blank.
|
||||
if self._state_before_power_off is None and previous not in ("", "unknown"):
|
||||
self._state_before_power_off = previous
|
||||
return True
|
||||
|
||||
def _restore_state_after_false_power_off(self) -> bool:
|
||||
"""Undo a presumed power-off once the printer proves it is alive.
|
||||
|
||||
``connected`` self-heals on the next message, but ``state`` does not:
|
||||
it is only rewritten when a payload carries ``gcode_state``, and the
|
||||
steady-state ``push_status`` frames are partial. Without this the
|
||||
forced "unknown" sticks until a full pushall (a manual Force Refresh),
|
||||
and the queue scheduler treats the printer as not idle the whole time
|
||||
(#2629). Returns True when a state was restored.
|
||||
"""
|
||||
previous = self._state_before_power_off
|
||||
self._state_before_power_off = None
|
||||
if previous is None or self.state.state != "unknown":
|
||||
return False
|
||||
logger.info(
|
||||
"[%s] Printer still responding after presumed power-off — restoring state %s",
|
||||
self.serial_number,
|
||||
previous,
|
||||
)
|
||||
self.state.state = previous
|
||||
return True
|
||||
|
||||
# Minimum seconds between stale reconnect attempts. Frontend polls
|
||||
# status every few seconds — without a cooldown, each poll would
|
||||
# force-close the socket before paho has time to reconnect.
|
||||
@@ -897,6 +951,12 @@ class BambuMQTTClient:
|
||||
if rc == 0:
|
||||
self.state.connected = True
|
||||
self._stale_reconnecting = False # Clear stale-reconnect flag on successful connect
|
||||
# A dropped-and-restored MQTT session means the presumed power-off was
|
||||
# real (or at least that the printer restarted): there is nothing
|
||||
# legitimate left to restore, and the printer will send a full status
|
||||
# push shortly. Dropping the saved state keeps a stale one from being
|
||||
# broadcast ahead of the first real report (#2629, #1679).
|
||||
self._state_before_power_off = None
|
||||
# Reset per-connection warning state so warnings fire once per (re)connection
|
||||
self._ams_version_warned = set()
|
||||
# Preserve cached developer_mode across auto-reconnects to avoid
|
||||
@@ -1063,6 +1123,11 @@ class BambuMQTTClient:
|
||||
# "printer never sent a report" apart from a mid-session quiet gap.
|
||||
if msg.topic == self.topic_subscribe:
|
||||
self._report_messages_since_connect += 1
|
||||
# Only report-topic traffic proves the *printer* is alive — the
|
||||
# request topic also carries slicer/Bambuddy commands.
|
||||
if self._state_before_power_off is not None:
|
||||
if self._restore_state_after_false_power_off() and self.on_state_change:
|
||||
self.on_state_change(self.state)
|
||||
|
||||
# Log message if logging is enabled
|
||||
if self._logging_enabled:
|
||||
|
||||
@@ -518,11 +518,13 @@ class PrintScheduler:
|
||||
auto_on_plugs = [p for p in plugs if p.auto_on and p.enabled]
|
||||
if auto_on_plugs:
|
||||
logger.info("Printer %s offline, attempting to power on via smart plug(s)", item.printer_id)
|
||||
# Power on using the first auto_on plug (the printer power plug)
|
||||
powered_on = await self._power_on_and_wait(auto_on_plugs[0], item.printer_id, db)
|
||||
# Power on using the plug that actually feeds the printer, and
|
||||
# wait for it to boot on that one only (#2629).
|
||||
primary_plug = self._pick_power_plug(auto_on_plugs)
|
||||
powered_on = await self._power_on_and_wait(primary_plug, item.printer_id, db)
|
||||
if powered_on:
|
||||
# Also turn on any remaining auto_on plugs (e.g., filter)
|
||||
for extra_plug in auto_on_plugs[1:]:
|
||||
for extra_plug in [p for p in auto_on_plugs if p.id != primary_plug.id]:
|
||||
try:
|
||||
service = await smart_plug_manager.get_service_for_plug(extra_plug, db)
|
||||
await service.turn_on(extra_plug)
|
||||
@@ -2407,6 +2409,21 @@ class PrintScheduler:
|
||||
result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
|
||||
return list(result.scalars().all())
|
||||
|
||||
@staticmethod
|
||||
def _pick_power_plug(auto_on_plugs: list[SmartPlug]) -> SmartPlug:
|
||||
"""Pick the plug to power-cycle a printer back online with (#2629).
|
||||
|
||||
Only a plug flagged ``controls_printer_power`` can actually bring the
|
||||
printer back; waiting for a boot on an accessory (filter fan, lights)
|
||||
just burns the power-on timeout and fails the dispatch. Falls back to
|
||||
the first plug when none is flagged, which is the pre-#2629 behaviour.
|
||||
Callers must pass a non-empty list.
|
||||
"""
|
||||
for plug in auto_on_plugs:
|
||||
if plug.controls_printer_power:
|
||||
return plug
|
||||
return auto_on_plugs[0]
|
||||
|
||||
# Bundled defaults for preheat_filament_targets (#1468). Values are the
|
||||
# chamber-temperature recommendations BambuStudio ships for the matching
|
||||
# filament profile; users can override via Settings → Workflow → Preheat
|
||||
|
||||
@@ -698,6 +698,11 @@ class PrinterManager:
|
||||
|
||||
This is used when we know the printer power was cut (e.g., smart plug turned off)
|
||||
to immediately update the UI without waiting for MQTT timeout.
|
||||
|
||||
The mark is a presumption, not a fact: the plug may not actually feed
|
||||
the printer. ``BambuMQTTClient.mark_power_off`` records the state it
|
||||
overwrites so the client can undo it as soon as the printer sends
|
||||
another report (#2629).
|
||||
"""
|
||||
import logging
|
||||
|
||||
@@ -705,10 +710,8 @@ class PrinterManager:
|
||||
|
||||
if printer_id in self._clients:
|
||||
client = self._clients[printer_id]
|
||||
if client.state.connected:
|
||||
if client.mark_power_off():
|
||||
logger.info("Marking printer %s as offline (smart plug power off)", printer_id)
|
||||
client.state.connected = False
|
||||
client.state.state = "unknown"
|
||||
# Trigger the status change callback to broadcast via WebSocket
|
||||
if self._on_status_change:
|
||||
self._schedule_async(self._on_status_change(printer_id, client.state))
|
||||
|
||||
@@ -215,8 +215,8 @@ class SmartPlugManager:
|
||||
plug.last_state = "OFF"
|
||||
plug.last_checked = utcnow_naive()
|
||||
self._last_schedule_check[plug.id] = f"off:{current_time}"
|
||||
# Mark printer offline if linked
|
||||
if plug.printer_id:
|
||||
# Mark printer offline if this plug feeds it (#2629)
|
||||
if plug.printer_id and plug.controls_printer_power:
|
||||
printer_manager.mark_printer_offline(plug.printer_id)
|
||||
|
||||
await db.commit()
|
||||
@@ -410,6 +410,7 @@ class SmartPlugManager:
|
||||
plug.password,
|
||||
printer_id,
|
||||
delay_seconds,
|
||||
controls_printer_power=plug.controls_printer_power,
|
||||
rest_off_url=plug.rest_off_url if plug.plug_type == "rest" else None,
|
||||
rest_off_body=plug.rest_off_body if plug.plug_type == "rest" else None,
|
||||
rest_method=plug.rest_method if plug.plug_type == "rest" else None,
|
||||
@@ -429,6 +430,7 @@ class SmartPlugManager:
|
||||
printer_id: int,
|
||||
delay_seconds: int,
|
||||
*,
|
||||
controls_printer_power: bool = True,
|
||||
rest_off_url: str | None = None,
|
||||
rest_off_body: str | None = None,
|
||||
rest_method: str | None = None,
|
||||
@@ -476,8 +478,10 @@ class SmartPlugManager:
|
||||
# Mark auto_off_executed in database and update printer status
|
||||
if success:
|
||||
await self._mark_auto_off_executed(plug_id)
|
||||
# Mark the printer as offline immediately
|
||||
printer_manager.mark_printer_offline(printer_id)
|
||||
# Mark the printer as offline immediately — but only when this
|
||||
# plug actually feeds the printer (#2629).
|
||||
if controls_printer_power:
|
||||
printer_manager.mark_printer_offline(printer_id)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.debug("Delayed turn-off cancelled for plug %s", plug_id)
|
||||
@@ -504,6 +508,7 @@ class SmartPlugManager:
|
||||
plug.password,
|
||||
printer_id,
|
||||
temp_threshold,
|
||||
controls_printer_power=plug.controls_printer_power,
|
||||
rest_off_url=plug.rest_off_url if plug.plug_type == "rest" else None,
|
||||
rest_off_body=plug.rest_off_body if plug.plug_type == "rest" else None,
|
||||
rest_method=plug.rest_method if plug.plug_type == "rest" else None,
|
||||
@@ -523,6 +528,7 @@ class SmartPlugManager:
|
||||
printer_id: int,
|
||||
temp_threshold: int,
|
||||
*,
|
||||
controls_printer_power: bool = True,
|
||||
rest_off_url: str | None = None,
|
||||
rest_off_body: str | None = None,
|
||||
rest_method: str | None = None,
|
||||
@@ -603,8 +609,10 @@ class SmartPlugManager:
|
||||
# Mark auto_off_executed in database and update printer status
|
||||
if success:
|
||||
await self._mark_auto_off_executed(plug_id)
|
||||
# Mark the printer as offline immediately
|
||||
printer_manager.mark_printer_offline(printer_id)
|
||||
# Mark the printer as offline immediately — but only
|
||||
# when this plug actually feeds the printer (#2629).
|
||||
if controls_printer_power:
|
||||
printer_manager.mark_printer_offline(printer_id)
|
||||
|
||||
break
|
||||
|
||||
@@ -739,7 +747,8 @@ class SmartPlugManager:
|
||||
success = await service.turn_off(plug)
|
||||
if success:
|
||||
await self._mark_auto_off_executed(plug.id)
|
||||
printer_manager.mark_printer_offline(plug.printer_id)
|
||||
if plug.controls_printer_power:
|
||||
printer_manager.mark_printer_offline(plug.printer_id)
|
||||
|
||||
if pending_plugs:
|
||||
logger.info("Resumed %s pending auto-off(s)", len(pending_plugs))
|
||||
|
||||
@@ -6320,3 +6320,151 @@ class TestLastLayerFinishPhotoTrigger:
|
||||
|
||||
assert len(events) == 1
|
||||
assert len(completion_events) == 1
|
||||
|
||||
|
||||
class TestPresumedPowerOffRecovery:
|
||||
"""#2629: a smart-plug turn-off marks the printer offline optimistically.
|
||||
|
||||
When the plug does not actually feed the printer, the printer keeps
|
||||
publishing — and the forced 'unknown' state must be undone, or it sticks
|
||||
until the next full pushall and the queue scheduler stalls forever.
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def mqtt_client(self):
|
||||
from backend.app.services.bambu_mqtt import BambuMQTTClient
|
||||
|
||||
client = BambuMQTTClient(
|
||||
ip_address="192.168.1.100",
|
||||
serial_number="TEST123",
|
||||
access_code="12345678",
|
||||
)
|
||||
client.state.connected = True
|
||||
client.state.state = "FINISH"
|
||||
return client
|
||||
|
||||
@staticmethod
|
||||
def _report(client, payload):
|
||||
"""Feed a report-topic message through the real _on_message path."""
|
||||
|
||||
class _Msg:
|
||||
def __init__(self, topic, data):
|
||||
self.topic = topic
|
||||
self.payload = json.dumps(data).encode()
|
||||
|
||||
client._on_message(None, None, _Msg(client.topic_subscribe, payload))
|
||||
|
||||
def test_mark_power_off_blanks_state_and_remembers_it(self, mqtt_client):
|
||||
assert mqtt_client.mark_power_off() is True
|
||||
|
||||
assert mqtt_client.state.connected is False
|
||||
assert mqtt_client.state.state == "unknown"
|
||||
assert mqtt_client._state_before_power_off == "FINISH"
|
||||
|
||||
def test_mark_power_off_noop_when_already_disconnected(self, mqtt_client):
|
||||
mqtt_client.state.connected = False
|
||||
|
||||
assert mqtt_client.mark_power_off() is False
|
||||
assert mqtt_client._state_before_power_off is None
|
||||
|
||||
def test_second_mark_does_not_overwrite_saved_state(self, mqtt_client):
|
||||
mqtt_client.mark_power_off()
|
||||
# Something flips connected back (a partial message) before the second mark
|
||||
mqtt_client.state.connected = True
|
||||
mqtt_client.mark_power_off()
|
||||
|
||||
assert mqtt_client._state_before_power_off == "FINISH"
|
||||
|
||||
def test_partial_report_restores_state(self, mqtt_client):
|
||||
"""The steady-state push_status carries no gcode_state — the pre-off
|
||||
state must come back anyway, otherwise 'unknown' is permanent."""
|
||||
mqtt_client.mark_power_off()
|
||||
|
||||
self._report(mqtt_client, {"print": {"wifi_signal": "-30dBm"}})
|
||||
|
||||
assert mqtt_client.state.connected is True
|
||||
assert mqtt_client.state.state == "FINISH"
|
||||
assert mqtt_client._state_before_power_off is None
|
||||
|
||||
def test_restore_broadcasts_state_change(self, mqtt_client):
|
||||
broadcasts = []
|
||||
mqtt_client.on_state_change = lambda state: broadcasts.append(state.state)
|
||||
mqtt_client.mark_power_off()
|
||||
|
||||
self._report(mqtt_client, {"print": {"wifi_signal": "-30dBm"}})
|
||||
|
||||
assert "FINISH" in broadcasts
|
||||
|
||||
def test_fresh_gcode_state_wins_over_restored_state(self, mqtt_client):
|
||||
"""A report that does carry gcode_state is authoritative."""
|
||||
mqtt_client.mark_power_off()
|
||||
|
||||
self._report(mqtt_client, {"print": {"gcode_state": "IDLE"}})
|
||||
|
||||
assert mqtt_client.state.state == "IDLE"
|
||||
|
||||
def test_restore_happens_only_once(self, mqtt_client):
|
||||
"""After recovery a later genuine blank must not be undone by a stale
|
||||
saved state."""
|
||||
mqtt_client.mark_power_off()
|
||||
self._report(mqtt_client, {"print": {"wifi_signal": "-30dBm"}})
|
||||
|
||||
# Printer really loses power now: state blanked, nothing to restore from
|
||||
mqtt_client.state.state = "unknown"
|
||||
assert mqtt_client._restore_state_after_false_power_off() is False
|
||||
assert mqtt_client.state.state == "unknown"
|
||||
|
||||
def test_request_topic_traffic_does_not_restore(self, mqtt_client):
|
||||
"""Only the printer's own report topic proves it is alive; the request
|
||||
topic also carries slicer/Bambuddy commands."""
|
||||
mqtt_client.mark_power_off()
|
||||
|
||||
class _Msg:
|
||||
topic = mqtt_client.topic_publish
|
||||
payload = json.dumps({"print": {"command": "project_file"}}).encode()
|
||||
|
||||
mqtt_client._on_message(None, None, _Msg())
|
||||
|
||||
assert mqtt_client.state.state == "unknown"
|
||||
assert mqtt_client._state_before_power_off == "FINISH"
|
||||
|
||||
def test_reconnect_discards_saved_state(self, mqtt_client):
|
||||
"""A real power cut drops the MQTT session; on reconnect the saved state
|
||||
is stale and must not be broadcast ahead of the printer's first report."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
mqtt_client.mark_power_off()
|
||||
|
||||
paho = MagicMock()
|
||||
paho.subscribe.return_value = (0, 1) # (MQTT_ERR_SUCCESS, mid)
|
||||
mqtt_client._on_connect(paho, None, {}, 0)
|
||||
|
||||
assert mqtt_client._state_before_power_off is None
|
||||
|
||||
self._report(mqtt_client, {"print": {"wifi_signal": "-30dBm"}})
|
||||
|
||||
assert mqtt_client.state.state == "unknown"
|
||||
|
||||
def test_already_unknown_state_is_not_saved(self, mqtt_client):
|
||||
"""A printer that never reported has nothing to restore — saving
|
||||
'unknown' would make the recovery broadcast a no-op state change."""
|
||||
mqtt_client.state.state = "unknown"
|
||||
|
||||
assert mqtt_client.mark_power_off() is True
|
||||
assert mqtt_client._state_before_power_off is None
|
||||
|
||||
def test_message_interleaved_with_mark_does_not_strand_unknown(self, mqtt_client):
|
||||
"""mark_power_off runs on the event loop, _on_message on the paho
|
||||
thread. A message landing mid-mark must not consume the saved state and
|
||||
leave the printer stuck on 'unknown' — the next message must recover."""
|
||||
# Simulate the worst interleaving: a report is processed after the state
|
||||
# was blanked but before the previous state was recorded.
|
||||
mqtt_client.state.connected = False
|
||||
mqtt_client.state.state = "unknown"
|
||||
self._report(mqtt_client, {"print": {"wifi_signal": "-30dBm"}})
|
||||
# ...now the rest of the mark completes.
|
||||
mqtt_client._state_before_power_off = "FINISH"
|
||||
|
||||
self._report(mqtt_client, {"print": {"wifi_signal": "-30dBm"}})
|
||||
|
||||
assert mqtt_client.state.state == "FINISH"
|
||||
|
||||
@@ -52,6 +52,19 @@ class TestPrinterManager:
|
||||
client.state.temperatures = {"nozzle": 25, "bed": 25}
|
||||
client.state.raw_data = {}
|
||||
client.logging_enabled = False
|
||||
|
||||
# mark_power_off is real logic on BambuMQTTClient (#2629) — mirror it so
|
||||
# the manager tests still exercise the state transition they assert on.
|
||||
# The real implementation (and its recovery path) is covered in
|
||||
# test_bambu_mqtt.py::TestPresumedPowerOffRecovery.
|
||||
def _mark_power_off():
|
||||
if not client.state.connected:
|
||||
return False
|
||||
client.state.connected = False
|
||||
client.state.state = "unknown"
|
||||
return True
|
||||
|
||||
client.mark_power_off.side_effect = _mark_power_off
|
||||
return client
|
||||
|
||||
# ========================================================================
|
||||
|
||||
@@ -1051,3 +1051,158 @@ class TestActivePrintGuard:
|
||||
mock_task.cancel.assert_called_once() # cancelled despite auto_on=False
|
||||
assert mock_plug.id not in manager._pending_off
|
||||
mock_tasmota.turn_on.assert_not_called() # but not powered on
|
||||
|
||||
|
||||
class TestAccessoryPlugDoesNotMarkPrinterOffline:
|
||||
"""#2629 — a plug linked to a printer is not necessarily its power supply.
|
||||
|
||||
Filter fans, chamber lights and enclosure heaters are linked so they follow
|
||||
the print cycle. Marking the printer offline when one of those switches off
|
||||
blanks the printer state and stalls the queue until a manual Force Refresh.
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def manager(self):
|
||||
return SmartPlugManager()
|
||||
|
||||
@pytest.fixture
|
||||
def accessory_plug(self):
|
||||
plug = MagicMock()
|
||||
plug.id = 1
|
||||
plug.name = "BentoBox Filter"
|
||||
plug.ip_address = "192.168.1.100"
|
||||
plug.username = None
|
||||
plug.password = None
|
||||
plug.enabled = True
|
||||
plug.auto_off = True
|
||||
plug.off_delay_mode = "time"
|
||||
plug.off_delay_minutes = 1
|
||||
plug.off_temp_threshold = 70
|
||||
plug.printer_id = 1
|
||||
plug.plug_type = "tasmota"
|
||||
plug.ha_entity_id = None
|
||||
plug.controls_printer_power = False
|
||||
return plug
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delayed_off_skips_offline_mark_for_accessory(self, manager):
|
||||
mock_service = AsyncMock()
|
||||
mock_service.turn_off = AsyncMock(return_value=True)
|
||||
with (
|
||||
patch("backend.app.services.smart_plug_manager.printer_manager") as mock_pm,
|
||||
patch.object(manager, "get_service_for_plug", new_callable=AsyncMock, return_value=mock_service),
|
||||
patch.object(manager, "_mark_auto_off_executed", new_callable=AsyncMock),
|
||||
):
|
||||
mock_pm.is_print_active.return_value = False
|
||||
|
||||
await manager._delayed_off(
|
||||
1, "tasmota", "1.2.3.4", None, None, None, printer_id=1, delay_seconds=0, controls_printer_power=False
|
||||
)
|
||||
|
||||
mock_service.turn_off.assert_awaited_once() # the plug still switches off
|
||||
mock_pm.mark_printer_offline.assert_not_called() # but the printer is untouched
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_temp_based_off_skips_offline_mark_for_accessory(self, manager):
|
||||
mock_service = AsyncMock()
|
||||
mock_service.turn_off = AsyncMock(return_value=True)
|
||||
with (
|
||||
patch("backend.app.services.smart_plug_manager.printer_manager") as mock_pm,
|
||||
patch("backend.app.services.smart_plug_manager.asyncio.sleep", new_callable=AsyncMock),
|
||||
patch.object(manager, "get_service_for_plug", new_callable=AsyncMock, return_value=mock_service),
|
||||
patch.object(manager, "_mark_auto_off_executed", new_callable=AsyncMock),
|
||||
):
|
||||
mock_pm.get_status.return_value = MagicMock(state="FINISH", temperatures={"nozzle": 40})
|
||||
mock_pm.is_print_active.return_value = False
|
||||
|
||||
await manager._temp_based_off(
|
||||
1,
|
||||
"tasmota",
|
||||
"1.2.3.4",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
printer_id=1,
|
||||
temp_threshold=55,
|
||||
controls_printer_power=False,
|
||||
)
|
||||
|
||||
mock_service.turn_off.assert_awaited_once()
|
||||
mock_pm.mark_printer_offline.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_schedulers_forward_the_flag(self, manager, accessory_plug):
|
||||
"""The flag lives on the plug row; both schedulers must pass it into the
|
||||
detached task, which only receives primitives."""
|
||||
with (
|
||||
patch.object(manager, "_mark_auto_off_pending", new_callable=AsyncMock),
|
||||
patch.object(manager, "_delayed_off", new_callable=AsyncMock) as mock_delayed,
|
||||
patch.object(manager, "_temp_based_off", new_callable=AsyncMock) as mock_temp,
|
||||
):
|
||||
manager._schedule_delayed_off(accessory_plug, 1, 60)
|
||||
manager._schedule_temp_based_off(accessory_plug, 1, 70)
|
||||
|
||||
assert mock_delayed.call_args.kwargs["controls_printer_power"] is False
|
||||
assert mock_temp.call_args.kwargs["controls_printer_power"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scheduled_off_skips_offline_mark_for_accessory(self, manager, accessory_plug):
|
||||
"""The time-of-day schedule path has its own turn-off + offline mark."""
|
||||
accessory_plug.schedule_enabled = True
|
||||
accessory_plug.schedule_on_time = None
|
||||
accessory_plug.schedule_off_time = "22:00"
|
||||
with (
|
||||
patch("backend.app.services.smart_plug_manager.datetime") as mock_datetime,
|
||||
patch("backend.app.core.database.async_session") as mock_session_ctx,
|
||||
patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
|
||||
patch("backend.app.services.smart_plug_manager.printer_manager") as mock_pm,
|
||||
):
|
||||
mock_now = MagicMock()
|
||||
mock_now.strftime.return_value = "22:00"
|
||||
mock_datetime.now.return_value = mock_now
|
||||
|
||||
mock_db = AsyncMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.all.return_value = [accessory_plug]
|
||||
mock_db.execute = AsyncMock(return_value=mock_result)
|
||||
mock_db.commit = AsyncMock()
|
||||
mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
|
||||
mock_session_ctx.return_value.__aexit__ = AsyncMock()
|
||||
|
||||
mock_tasmota.turn_off = AsyncMock(return_value=True)
|
||||
|
||||
await manager._check_schedules()
|
||||
|
||||
mock_tasmota.turn_off.assert_awaited_once_with(accessory_plug)
|
||||
mock_pm.mark_printer_offline.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scheduled_off_still_marks_offline_for_power_plug(self, manager, accessory_plug):
|
||||
"""Default (a plug that really feeds the printer) keeps the old behaviour."""
|
||||
accessory_plug.controls_printer_power = True
|
||||
accessory_plug.schedule_enabled = True
|
||||
accessory_plug.schedule_on_time = None
|
||||
accessory_plug.schedule_off_time = "22:00"
|
||||
with (
|
||||
patch("backend.app.services.smart_plug_manager.datetime") as mock_datetime,
|
||||
patch("backend.app.core.database.async_session") as mock_session_ctx,
|
||||
patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
|
||||
patch("backend.app.services.smart_plug_manager.printer_manager") as mock_pm,
|
||||
):
|
||||
mock_now = MagicMock()
|
||||
mock_now.strftime.return_value = "22:00"
|
||||
mock_datetime.now.return_value = mock_now
|
||||
|
||||
mock_db = AsyncMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.all.return_value = [accessory_plug]
|
||||
mock_db.execute = AsyncMock(return_value=mock_result)
|
||||
mock_db.commit = AsyncMock()
|
||||
mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
|
||||
mock_session_ctx.return_value.__aexit__ = AsyncMock()
|
||||
|
||||
mock_tasmota.turn_off = AsyncMock(return_value=True)
|
||||
|
||||
await manager._check_schedules()
|
||||
|
||||
mock_pm.mark_printer_offline.assert_called_once_with(1)
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""End-to-end regression test for the #2629 queue stall.
|
||||
|
||||
Exercises the real objects rather than mocks: a real ``BambuMQTTClient``
|
||||
registered on the real ``printer_manager`` singleton, driven through the real
|
||||
``_on_message`` path, and read back through the scheduler's own idle check.
|
||||
That chain — presume power off, printer keeps talking, scheduler sees it as
|
||||
dispatchable again — is what actually broke for the reporter, and no single
|
||||
unit test covers it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.app.services.bambu_mqtt import BambuMQTTClient
|
||||
from backend.app.services.print_scheduler import PrintScheduler
|
||||
from backend.app.services.printer_manager import printer_manager
|
||||
|
||||
PRINTER_ID = 9629 # unlikely to collide with any other test's registrations
|
||||
|
||||
|
||||
class _Msg:
|
||||
def __init__(self, topic: str, data: dict):
|
||||
self.topic = topic
|
||||
self.payload = json.dumps(data).encode()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def registered_client():
|
||||
"""A connected client sitting on FINISH, as after a completed print."""
|
||||
client = BambuMQTTClient(ip_address="10.0.0.5", serial_number="SER2629", access_code="12345678")
|
||||
client.state.connected = True
|
||||
client.state.state = "FINISH"
|
||||
printer_manager._clients[PRINTER_ID] = client
|
||||
try:
|
||||
yield client
|
||||
finally:
|
||||
printer_manager._clients.pop(PRINTER_ID, None)
|
||||
|
||||
|
||||
def _partial_push(client: BambuMQTTClient) -> None:
|
||||
"""A steady-state push_status carrying no gcode_state — the frame shape the
|
||||
reporter's P1S sends between state transitions."""
|
||||
client._on_message(None, None, _Msg(client.topic_subscribe, {"print": {"wifi_signal": "-30dBm"}}))
|
||||
|
||||
|
||||
def test_printer_recovers_and_queue_can_dispatch_again(registered_client):
|
||||
scheduler = PrintScheduler()
|
||||
assert scheduler._is_printer_idle(PRINTER_ID, require_plate_clear=False) is True
|
||||
|
||||
# An accessory plug (filter fan) switches off; Bambuddy presumes power loss.
|
||||
printer_manager.mark_printer_offline(PRINTER_ID)
|
||||
assert printer_manager.get_status(PRINTER_ID).state == "unknown"
|
||||
assert scheduler._is_printer_idle(PRINTER_ID, require_plate_clear=False) is False
|
||||
|
||||
# The printer never stopped talking.
|
||||
_partial_push(registered_client)
|
||||
|
||||
assert printer_manager.get_status(PRINTER_ID).state == "FINISH"
|
||||
assert printer_manager.is_connected(PRINTER_ID) is True
|
||||
assert scheduler._is_printer_idle(PRINTER_ID, require_plate_clear=False) is True
|
||||
|
||||
|
||||
def test_real_power_cut_still_leaves_printer_unavailable(registered_client):
|
||||
"""The recovery must key off actual traffic, not off time passing — a plug
|
||||
that really cut power produces silence, and the printer stays offline."""
|
||||
scheduler = PrintScheduler()
|
||||
|
||||
printer_manager.mark_printer_offline(PRINTER_ID)
|
||||
|
||||
# No messages arrive at all.
|
||||
assert printer_manager.get_status(PRINTER_ID).state == "unknown"
|
||||
assert printer_manager.is_connected(PRINTER_ID) is False
|
||||
assert scheduler._is_printer_idle(PRINTER_ID, require_plate_clear=False) is False
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Tests for _pick_power_plug() in the print scheduler (#2629).
|
||||
|
||||
A printer can have several plugs linked to it: the one feeding the printer and
|
||||
accessories that merely follow the print cycle (filter fan, chamber light). Only
|
||||
the former can bring an offline printer back, so the queue's power-on step must
|
||||
pick it rather than whichever row came back first.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from backend.app.services.print_scheduler import PrintScheduler
|
||||
|
||||
|
||||
def _plug(plug_id: int, name: str, controls_printer_power: bool) -> SimpleNamespace:
|
||||
return SimpleNamespace(id=plug_id, name=name, controls_printer_power=controls_printer_power)
|
||||
|
||||
|
||||
class TestPickPowerPlug:
|
||||
def test_prefers_power_plug_over_earlier_accessory(self):
|
||||
fan = _plug(1, "BentoBox Filter", False)
|
||||
printer_plug = _plug(2, "P1S Power", True)
|
||||
|
||||
assert PrintScheduler._pick_power_plug([fan, printer_plug]) is printer_plug
|
||||
|
||||
def test_keeps_first_power_plug_when_several_qualify(self):
|
||||
first = _plug(1, "P1S Power", True)
|
||||
second = _plug(2, "Bench Power", True)
|
||||
|
||||
assert PrintScheduler._pick_power_plug([first, second]) is first
|
||||
|
||||
def test_falls_back_to_first_when_none_flagged(self):
|
||||
"""Pre-#2629 behaviour for setups where no plug is marked as the power
|
||||
source — powering on may not work, but nothing gets worse."""
|
||||
fan = _plug(1, "BentoBox Filter", False)
|
||||
light = _plug(2, "Chamber Light", False)
|
||||
|
||||
assert PrintScheduler._pick_power_plug([fan, light]) is fan
|
||||
|
||||
def test_single_plug_is_returned_regardless(self):
|
||||
only = _plug(1, "P1S Power", True)
|
||||
|
||||
assert PrintScheduler._pick_power_plug([only]) is only
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Migration test for #2629 — smart_plugs.controls_printer_power.
|
||||
|
||||
Existing installs have plugs that were assumed to power their linked printer, so
|
||||
the new column must be added *and backfilled to true*: a NULL or false backfill
|
||||
would silently stop marking a real printer plug's power-off, which is the
|
||||
behaviour users have today.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from backend.app.core.database import run_migrations
|
||||
|
||||
LEGACY_SMART_PLUGS = """
|
||||
CREATE TABLE smart_plugs (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
ip_address VARCHAR(45),
|
||||
plug_type VARCHAR(20) DEFAULT 'tasmota',
|
||||
ha_entity_id VARCHAR(100),
|
||||
printer_id INTEGER,
|
||||
enabled BOOLEAN DEFAULT 1,
|
||||
auto_on BOOLEAN DEFAULT 1,
|
||||
auto_off BOOLEAN DEFAULT 1,
|
||||
auto_off_persistent BOOLEAN DEFAULT 0,
|
||||
off_delay_mode VARCHAR(20) DEFAULT 'time',
|
||||
off_delay_minutes INTEGER DEFAULT 5,
|
||||
off_temp_threshold INTEGER DEFAULT 70,
|
||||
show_in_switchbar BOOLEAN DEFAULT 0,
|
||||
show_on_printer_card BOOLEAN DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def force_sqlite_dialect(monkeypatch):
|
||||
"""settings.database_url may point at Postgres in dev configs; the test engine
|
||||
is SQLite, so force the dialect both places run_migrations reads it from."""
|
||||
from backend.app.core import database as database_module, db_dialect
|
||||
|
||||
monkeypatch.setattr(db_dialect, "is_sqlite", lambda: True)
|
||||
monkeypatch.setattr(db_dialect, "is_postgres", lambda: False)
|
||||
monkeypatch.setattr(database_module, "is_sqlite", lambda: True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def legacy_engine():
|
||||
"""A modern schema with a pre-#2629 smart_plugs table holding one plug."""
|
||||
from backend.app.core.database import Base
|
||||
from backend.app.models import ( # noqa: F401
|
||||
ams_history,
|
||||
ams_label,
|
||||
api_key,
|
||||
archive,
|
||||
color_catalog,
|
||||
external_link,
|
||||
filament,
|
||||
group,
|
||||
kprofile_note,
|
||||
maintenance,
|
||||
notification,
|
||||
notification_template,
|
||||
print_log,
|
||||
print_queue,
|
||||
printer,
|
||||
project,
|
||||
project_bom,
|
||||
settings,
|
||||
slot_preset,
|
||||
smart_plug,
|
||||
smart_plug_energy_snapshot,
|
||||
spool,
|
||||
spool_assignment,
|
||||
spool_catalog,
|
||||
spool_k_profile,
|
||||
spool_usage_history,
|
||||
spoolbuddy_device,
|
||||
user,
|
||||
user_email_pref,
|
||||
virtual_printer,
|
||||
)
|
||||
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
await conn.execute(text("DROP TABLE smart_plugs"))
|
||||
await conn.execute(text(LEGACY_SMART_PLUGS))
|
||||
await conn.execute(
|
||||
text("INSERT INTO smart_plugs (id, name, plug_type, printer_id) VALUES (1, 'P1S Power', 'tasmota', 1)")
|
||||
)
|
||||
yield engine
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_column_missing_before_migration(legacy_engine):
|
||||
"""Sanity check so the assertion below can't pass by accident."""
|
||||
async with legacy_engine.begin() as conn:
|
||||
columns = {row[1] for row in await conn.execute(text("PRAGMA table_info(smart_plugs)"))}
|
||||
assert "controls_printer_power" not in columns
|
||||
|
||||
|
||||
async def test_existing_plugs_backfill_to_true(legacy_engine):
|
||||
"""An upgraded install must keep marking its printer offline on power-off."""
|
||||
async with legacy_engine.begin() as conn:
|
||||
await run_migrations(conn)
|
||||
|
||||
async with legacy_engine.begin() as conn:
|
||||
result = await conn.execute(text("SELECT controls_printer_power FROM smart_plugs WHERE id = 1"))
|
||||
assert bool(result.scalar_one()) is True
|
||||
|
||||
|
||||
async def test_migration_is_idempotent(legacy_engine):
|
||||
"""Second boot must not fail on the already-present column."""
|
||||
async with legacy_engine.begin() as conn:
|
||||
await run_migrations(conn)
|
||||
async with legacy_engine.begin() as conn:
|
||||
await run_migrations(conn)
|
||||
|
||||
async with legacy_engine.begin() as conn:
|
||||
result = await conn.execute(text("SELECT controls_printer_power FROM smart_plugs WHERE id = 1"))
|
||||
assert bool(result.scalar_one()) is True
|
||||
|
||||
|
||||
class TestPostgresBranch:
|
||||
"""CI runs on SQLite, so the Postgres branch of the dialect switch would be
|
||||
dead code without this. Captures the SQL ``run_migrations`` would emit,
|
||||
mirroring ``test_oidc_icon_migration_pg.py``.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
async def _capture_sql(is_sqlite_value: bool) -> list[str]:
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from backend.app.core import database as db_module
|
||||
|
||||
class _AsyncCtxStub:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_exc):
|
||||
return False
|
||||
|
||||
executed_sql: list[str] = []
|
||||
|
||||
async def fake_safe_execute(_conn, sql: str) -> None:
|
||||
executed_sql.append(sql)
|
||||
|
||||
fake_conn = MagicMock()
|
||||
fake_conn.begin_nested = lambda: _AsyncCtxStub()
|
||||
fake_conn.execute = AsyncMock(return_value=MagicMock(fetchone=MagicMock(return_value=None)))
|
||||
|
||||
with (
|
||||
patch("backend.app.core.database.is_sqlite", return_value=is_sqlite_value),
|
||||
patch("backend.app.core.database._safe_execute", side_effect=fake_safe_execute),
|
||||
patch("backend.app.core.database._migrate_update_auto_link_constraint", AsyncMock()),
|
||||
patch("backend.app.core.database._migrate_widen_spoolman_slot_ams_id_range", AsyncMock()),
|
||||
):
|
||||
await db_module.run_migrations(fake_conn)
|
||||
|
||||
return executed_sql
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pg_branch_uses_true_and_if_not_exists(self):
|
||||
executed = await self._capture_sql(is_sqlite_value=False)
|
||||
stmts = [s for s in executed if "controls_printer_power" in s]
|
||||
|
||||
assert len(stmts) == 1, f"expected exactly one statement, got: {stmts!r}"
|
||||
assert "IF NOT EXISTS" in stmts[0] # idempotent on PG, which has no _safe_execute retry semantics
|
||||
assert "DEFAULT true" in stmts[0]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sqlite_branch_uses_numeric_default(self):
|
||||
"""SQLite has no true/false literal — the switch must not be inverted."""
|
||||
executed = await self._capture_sql(is_sqlite_value=True)
|
||||
stmts = [s for s in executed if "controls_printer_power" in s]
|
||||
|
||||
assert len(stmts) == 1
|
||||
assert "DEFAULT 1" in stmts[0]
|
||||
assert "true" not in stmts[0]
|
||||
@@ -40,6 +40,7 @@ const createMockPlug = (overrides: Partial<SmartPlug> = {}): SmartPlug => ({
|
||||
mqtt_state_path: null,
|
||||
mqtt_state_on_value: null,
|
||||
printer_id: 1,
|
||||
controls_printer_power: true,
|
||||
enabled: true,
|
||||
auto_on: true,
|
||||
auto_off: true,
|
||||
@@ -287,6 +288,33 @@ describe('SmartPlugCard', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('powers the printer toggle (#2629)', () => {
|
||||
it('shows the toggle when a printer is linked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const plug = createMockPlug({ printer_id: 1, controls_printer_power: false });
|
||||
render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);
|
||||
|
||||
await user.click(screen.getByText('Automation Settings'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Powers the printer')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('hides the toggle when no printer is linked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const plug = createMockPlug({ printer_id: null });
|
||||
render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);
|
||||
|
||||
await user.click(screen.getByText('Automation Settings'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Auto On')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText('Powers the printer')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('disabled state', () => {
|
||||
it('renders plug even when disabled', () => {
|
||||
const plug = createMockPlug({ enabled: false });
|
||||
|
||||
@@ -1928,6 +1928,9 @@ export interface SmartPlug {
|
||||
rest_energy_total_path: string | null;
|
||||
rest_energy_total_multiplier: number;
|
||||
printer_id: number | null;
|
||||
// #2629: only a plug that really feeds the printer may mark it offline when
|
||||
// switched off. Accessory plugs follow the print cycle without powering it.
|
||||
controls_printer_power: boolean;
|
||||
enabled: boolean;
|
||||
auto_on: boolean;
|
||||
auto_off: boolean;
|
||||
@@ -2004,6 +2007,8 @@ export interface SmartPlugCreate {
|
||||
rest_energy_total_path?: string | null;
|
||||
rest_energy_total_multiplier?: number;
|
||||
printer_id?: number | null;
|
||||
// #2629
|
||||
controls_printer_power?: boolean;
|
||||
enabled?: boolean;
|
||||
auto_on?: boolean;
|
||||
auto_off?: boolean;
|
||||
@@ -2072,6 +2077,8 @@ export interface SmartPlugUpdate {
|
||||
rest_energy_total_path?: string | null;
|
||||
rest_energy_total_multiplier?: number;
|
||||
printer_id?: number | null;
|
||||
// #2629
|
||||
controls_printer_power?: boolean;
|
||||
enabled?: boolean;
|
||||
auto_on?: boolean;
|
||||
auto_off?: boolean;
|
||||
|
||||
@@ -84,6 +84,9 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
|
||||
const energyTotalDropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [printerId, setPrinterId] = useState<number | null>(plug?.printer_id || null);
|
||||
// #2629: defaults to true (a plug linked to a printer usually powers it);
|
||||
// users turn it off for accessories like a filter fan or chamber light.
|
||||
const [controlsPrinterPower, setControlsPrinterPower] = useState(plug?.controls_printer_power ?? true);
|
||||
const [testResult, setTestResult] = useState<{ success: boolean; state?: string | null; device_name?: string | null } | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -388,6 +391,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
|
||||
username: plugType === 'tasmota' ? (username.trim() || null) : null,
|
||||
password: plugType === 'tasmota' ? (password.trim() || null) : null,
|
||||
printer_id: printerId,
|
||||
controls_printer_power: controlsPrinterPower,
|
||||
// Power alerts
|
||||
power_alert_enabled: powerAlertEnabled,
|
||||
power_alert_high: powerAlertHigh ? parseFloat(powerAlertHigh) : null,
|
||||
@@ -1505,6 +1509,26 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
|
||||
<p className="text-xs text-bambu-gray mt-1">
|
||||
{t('smartPlugs.linkingDescription')}
|
||||
</p>
|
||||
|
||||
{/* Whether the plug feeds the printer itself, or is an accessory
|
||||
that merely follows the print cycle (#2629). */}
|
||||
{printerId !== null && (
|
||||
<div className="flex items-center justify-between mt-3">
|
||||
<div className="pr-3">
|
||||
<p className="text-sm text-white">{t('smartPlugs.controlsPrinterPower')}</p>
|
||||
<p className="text-xs text-bambu-gray">{t('smartPlugs.controlsPrinterPowerDescription')}</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer shrink-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={controlsPrinterPower}
|
||||
onChange={(e) => setControlsPrinterPower(e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -317,6 +317,29 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Powers the printer (#2629) - only meaningful with a linked printer.
|
||||
When off, switching this plug off no longer marks the printer offline. */}
|
||||
{plug.printer_id != null && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Power className="w-4 h-4 text-bambu-green" />
|
||||
<div>
|
||||
<p className="text-sm text-white">{t('smartPlugs.controlsPrinterPower')}</p>
|
||||
<p className="text-xs text-bambu-gray">{t('smartPlugs.controlsPrinterPowerDescription')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={plug.controls_printer_power}
|
||||
onChange={(e) => updateMutation.mutate({ controls_printer_power: e.target.checked })}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Automation controls - only for controllable plugs (not MQTT) */}
|
||||
{plug.plug_type !== 'mqtt' && (
|
||||
<>
|
||||
|
||||
@@ -5279,6 +5279,8 @@ export default {
|
||||
autoOffDescription: 'Ausschalten wenn Druck abgeschlossen (einmalig)',
|
||||
autoOffPersistent: 'Aktiviert lassen',
|
||||
autoOffPersistentDescription: 'Zwischen Drucken aktiviert bleiben statt einmalig',
|
||||
controlsPrinterPower: 'Versorgt den Drucker',
|
||||
controlsPrinterPowerDescription: 'Deaktivieren, wenn diese Steckdose nur Zubehör versorgt (Filterlüfter, Licht). Sonst wird der Drucker beim Ausschalten als offline markiert.',
|
||||
autoOffAfterDrying: 'Automatisch aus nach Trocknung',
|
||||
autoOffAfterDryingDescription: 'Ausschalten, wenn AMS-Trocknung abgeschlossen ist',
|
||||
delayAfterDryingMinutes: 'Verzögerung nach Trocknung (Minuten)',
|
||||
|
||||
@@ -5323,6 +5323,8 @@ export default {
|
||||
autoOffDescription: 'Turn off when print completes (one-shot)',
|
||||
autoOffPersistent: 'Keep Enabled',
|
||||
autoOffPersistentDescription: 'Stay enabled between prints instead of one-shot',
|
||||
controlsPrinterPower: 'Powers the printer',
|
||||
controlsPrinterPowerDescription: 'Turn off if this plug only powers an accessory (filter fan, lights). Otherwise switching it off marks the printer offline.',
|
||||
autoOffAfterDrying: 'Auto Off After Drying',
|
||||
autoOffAfterDryingDescription: 'Turn off when AMS drying completes',
|
||||
delayAfterDryingMinutes: 'Drying delay (minutes)',
|
||||
|
||||
@@ -5288,6 +5288,8 @@ export default {
|
||||
autoOffDescription: 'Apagar cuando se completa la impresión (una sola vez)',
|
||||
autoOffPersistent: 'Mantener activado',
|
||||
autoOffPersistentDescription: 'Permanecer activado entre impresiones en lugar de una sola vez',
|
||||
controlsPrinterPower: 'Alimenta la impresora',
|
||||
controlsPrinterPowerDescription: 'Desactívalo si este enchufe solo alimenta un accesorio (ventilador de filtro, luces). De lo contrario, apagarlo marca la impresora como desconectada.',
|
||||
autoOffAfterDrying: 'Apagado automático tras el secado',
|
||||
autoOffAfterDryingDescription: 'Apagar cuando se completa el secado del AMS',
|
||||
delayAfterDryingMinutes: 'Retardo de secado (minutos)',
|
||||
|
||||
@@ -5269,6 +5269,8 @@ export default {
|
||||
autoOffDescription: 'Éteindre à la fin de l\'impression (unique)',
|
||||
autoOffPersistent: 'Garder activé',
|
||||
autoOffPersistentDescription: 'Rester activé entre les impressions au lieu d\'une seule fois',
|
||||
controlsPrinterPower: 'Alimente l\'imprimante',
|
||||
controlsPrinterPowerDescription: 'Désactivez si cette prise n\'alimente qu\'un accessoire (ventilateur de filtre, éclairage). Sinon, l\'éteindre marque l\'imprimante hors ligne.',
|
||||
autoOffAfterDrying: 'Arrêt auto après séchage',
|
||||
autoOffAfterDryingDescription: 'Éteindre à la fin du séchage de l\'AMS',
|
||||
delayAfterDryingMinutes: 'Délai après séchage (minutes)',
|
||||
|
||||
@@ -5268,6 +5268,8 @@ export default {
|
||||
autoOffDescription: 'Spegni quando la stampa è completata (una tantum)',
|
||||
autoOffPersistent: 'Mantieni attivo',
|
||||
autoOffPersistentDescription: 'Resta attivo tra le stampe invece di una tantum',
|
||||
controlsPrinterPower: 'Alimenta la stampante',
|
||||
controlsPrinterPowerDescription: 'Disattiva se questa presa alimenta solo un accessorio (ventola del filtro, luci). Altrimenti spegnerla segna la stampante come offline.',
|
||||
autoOffAfterDrying: 'Spegni dopo asciugatura',
|
||||
autoOffAfterDryingDescription: 'Spegni al termine dell\'asciugatura AMS',
|
||||
delayAfterDryingMinutes: 'Ritardo dopo asciugatura (minuti)',
|
||||
|
||||
@@ -5280,6 +5280,8 @@ export default {
|
||||
autoOffDescription: '印刷完了時にオフにする(ワンショット)',
|
||||
autoOffPersistent: '有効のまま維持',
|
||||
autoOffPersistentDescription: 'ワンショットではなく印刷間で有効のまま維持',
|
||||
controlsPrinterPower: 'プリンターに給電',
|
||||
controlsPrinterPowerDescription: 'このプラグがアクセサリー(フィルターファン、照明)のみに給電する場合はオフにします。オンのままだと、電源を切ったときにプリンターがオフラインとして扱われます。',
|
||||
autoOffAfterDrying: '乾燥完了後に自動オフ',
|
||||
autoOffAfterDryingDescription: 'AMSの乾燥が完了したらオフにする',
|
||||
delayAfterDryingMinutes: '乾燥後の遅延(分)',
|
||||
|
||||
@@ -5010,6 +5010,8 @@ export default {
|
||||
autoOffDescription: '인쇄 완료 시 끄기 (1회)',
|
||||
autoOffPersistent: '계속 활성화',
|
||||
autoOffPersistentDescription: '1회성 대신 인쇄 사이에 활성화 유지',
|
||||
controlsPrinterPower: '프린터에 전원 공급',
|
||||
controlsPrinterPowerDescription: '이 플러그가 액세서리(필터 팬, 조명)에만 전원을 공급한다면 끄세요. 그렇지 않으면 플러그를 끌 때 프린터가 오프라인으로 표시됩니다.',
|
||||
turnOffDelayMode: '끄기 지연 모드',
|
||||
time: '시간',
|
||||
temp: '온도',
|
||||
|
||||
@@ -5268,6 +5268,8 @@ export default {
|
||||
autoOffDescription: 'Desligar quando a impressão terminar (única vez)',
|
||||
autoOffPersistent: 'Manter ativado',
|
||||
autoOffPersistentDescription: 'Permanecer ativado entre impressões em vez de única vez',
|
||||
controlsPrinterPower: 'Alimenta a impressora',
|
||||
controlsPrinterPowerDescription: 'Desative se esta tomada alimenta apenas um acessório (ventoinha do filtro, luzes). Caso contrário, desligá-la marca a impressora como offline.',
|
||||
autoOffAfterDrying: 'Desligar Após Secagem',
|
||||
autoOffAfterDryingDescription: 'Desligar quando a secagem do AMS terminar',
|
||||
delayAfterDryingMinutes: 'Atraso após secagem (minutos)',
|
||||
|
||||
@@ -4998,6 +4998,8 @@ export default {
|
||||
autoOffDescription: "Выключать после завершения печати (однократно)",
|
||||
autoOffPersistent: "Оставлять включённым",
|
||||
autoOffPersistentDescription: "Не сбрасывать автовыключение после печати",
|
||||
controlsPrinterPower: "Питает принтер",
|
||||
controlsPrinterPowerDescription: "Отключите, если эта розетка питает только аксессуар (вентилятор фильтра, подсветку). Иначе её выключение помечает принтер как офлайн.",
|
||||
autoOffAfterDrying: "Выключать после сушки",
|
||||
autoOffAfterDryingDescription: "Выключать после завершения сушки в AMS",
|
||||
delayAfterDryingMinutes: "Задержка после сушки (мин)",
|
||||
|
||||
@@ -5243,6 +5243,8 @@ export default {
|
||||
autoOffDescription: 'Baskı tamamlandığında kapat (tek seferlik)',
|
||||
autoOffPersistent: 'Etkin Tut',
|
||||
autoOffPersistentDescription: 'Tek seferlik yerine baskılar arasında etkin kal',
|
||||
controlsPrinterPower: 'Yazıcıya güç veriyor',
|
||||
controlsPrinterPowerDescription: 'Bu priz yalnızca bir aksesuara (filtre fanı, aydınlatma) güç veriyorsa kapatın. Aksi halde prizi kapatmak yazıcıyı çevrimdışı olarak işaretler.',
|
||||
autoOffAfterDrying: 'Kurutmadan Sonra Otomatik Kapat',
|
||||
autoOffAfterDryingDescription: 'AMS kurutması tamamlandığında kapat',
|
||||
delayAfterDryingMinutes: 'Kurutma gecikmesi (dakika)',
|
||||
|
||||
@@ -5268,6 +5268,8 @@ export default {
|
||||
autoOffDescription: '打印完成时关闭(一次性)',
|
||||
autoOffPersistent: '保持启用',
|
||||
autoOffPersistentDescription: '在打印之间保持启用而非一次性',
|
||||
controlsPrinterPower: '为打印机供电',
|
||||
controlsPrinterPowerDescription: '如果此插座仅为配件(滤芯风扇、灯光)供电,请关闭此选项;否则关闭插座会将打印机标记为离线。',
|
||||
autoOffAfterDrying: '干燥完成后自动关闭',
|
||||
autoOffAfterDryingDescription: 'AMS 干燥完成后关闭',
|
||||
delayAfterDryingMinutes: '干燥后延迟(分钟)',
|
||||
|
||||
@@ -5268,6 +5268,8 @@ export default {
|
||||
autoOffDescription: '列印完成時關閉(一次性)',
|
||||
autoOffPersistent: '保持啟用',
|
||||
autoOffPersistentDescription: '在列印之間保持啟用而非一次性',
|
||||
controlsPrinterPower: '為印表機供電',
|
||||
controlsPrinterPowerDescription: '若此插座僅為配件(濾網風扇、燈光)供電,請關閉此選項;否則關閉插座會將印表機標記為離線。',
|
||||
autoOffAfterDrying: '乾燥完成後自動關閉',
|
||||
autoOffAfterDryingDescription: 'AMS 乾燥完成後關閉',
|
||||
delayAfterDryingMinutes: '乾燥後延遲(分鐘)',
|
||||
|
||||
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-DCk9Jev0.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-D0xVmIdo.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CKAbipPc.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user