diff --git a/CHANGELOG.md b/CHANGELOG.md index 203f2a33a..3721915e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,8 @@ All notable changes to Bambuddy will be documented in this file. - **Per-VP "G-code injection" toggle for Studio Send / FTP uploads (#1516, contributed by @phieb)** — Queue-mode Virtual Printers gain a per-VP opt-in toggle that applies the Settings → G-code Snippets per-model start/end snippets to every job that lands via the VP — Bambu Studio's "Send", OrcaSlicer's "Print Plate", the VP's own FTP upload path. Before this change the snippets were only applied to items queued through the PrintModal's "Inject auto-print G-code" checkbox; VP-incoming jobs silently bypassed injection regardless of how the snippets were configured. **Default off so upgraders don't silently start injecting**: existing `gcode_snippets` installs keep their previous behaviour until the per-VP toggle is explicitly enabled. When on, the scheduler still no-ops unless `gcode_snippets` are configured for the target printer model, so the effective semantics are "inject when enabled AND snippets exist." **DB column:** new `virtual_printers.gcode_injection BOOLEAN DEFAULT FALSE` with a branched `is_sqlite()` migration (SQLite `DEFAULT 0` / Postgres `DEFAULT FALSE`) matching the `queue_force_color_match` / `tailscale_disabled` precedent. **Multi-plate stamping:** the flag is set on every plate's `PrintQueueItem` inside the per-plate loop introduced by #1697 / #1188, so a multi-plate "Send all" upload now gets snippets injected on each plate consistently — the original PR only stamped the first plate; the merge resolution wove the flag into the loop. **Live-toggle correctness:** the `_sync_from_db_locked` change detector now compares `instance.gcode_injection != vp.gcode_injection`, so toggling the value in the UI triggers a VP restart instead of letting the in-memory instance keep the stale flag and silently propagate it onto every subsequent upload — same shape as the #1552 family. Backed by a dedicated `test_sync_from_db_restarts_on_gcode_injection_toggle`. **UI:** new toggle on `VirtualPrinterCard.tsx` (queue mode only — the toggle is hidden in archive/review/proxy modes since the feature is queue-specific), with the standard `updateMutation` save-on-click + toast on success, plus the `pendingAction='gcodeInjection'` opacity dim during the round-trip. **PrintModal hardening:** when "Inject auto-print G-code" is ticked on a reprint at quantity > 1, the modal now routes ALL copies through the queue (not just copies 2..N) so the scheduler injects every dispatch — see the separate reprint-quantity entry below for the full motivation. A new `useEffect` clears the stale `gcodeInjection` state if the user ticks the box at quantity 2, then drops back to quantity 1 — the checkbox hides at that point and the state must follow, otherwise the immediate-reprint path would silently bypass injection. **Diagnostics:** the resolved start/end snippets (with `{placeholder}` substitution already applied) are logged at DEBUG so any "snippet didn't run" report can be traced from a log bundle. **i18n:** new `virtualPrinter.gcodeInjection.title` + `description` keys translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW); parity check 5188 leaves per locale, no English fallback. **Tests:** 2 new unit cases in `test_virtual_printer.py` (queue items opt in / out based on the VP flag), 2 new integration cases in `test_virtual_printer_api.py` (create defaults to false, PUT round-trips the value), 1 new sync-restart case, plus updates to `_make_db_vp` so the change-detector test fixture carries an explicit `False` rather than relying on `MagicMock` truthiness. 2 new PrintModal vitest cases pin the reprint dispatch matrix (injection ON queues all copies and dispatches none immediately; injection OFF keeps the immediate first copy and queues the rest). Full backend pytest 6167/6167; full frontend vitest 2154/2154; ruff clean; `npm run build` clean. +- **Heater history (nozzle / bed / chamber) tracked + charted, matching AMS sensor history** — Bambuddy already logged AMS humidity / temperature every 5 minutes and exposed them in a per-AMS history modal; the printer-side heaters (nozzle, optional second nozzle on H2D, bed, chamber) had no equivalent surface. New per-printer recorder logs heater readings every 60 s (heaters move faster than AMS humidity) into a new `printer_sensor_history` table — long format per `(printer_id, sensor_kind, value, target, recorded_at)` with `sensor_kind ∈ {nozzle, nozzle_2, bed, chamber}` so model variants (single vs dual nozzle, sensor-only X1C/P2S chamber vs heater-equipped X1E/H2D chamber) all fit without a wide-row migration when new sensors get added later. **Recorder source.** Pulls from `state.temperatures` (already normalised across model field-aliases like `nozzle_temper`, `left_nozzle_temper`, `right_nozzle_temper`, `chamber_temper` by the MQTT parser) rather than re-parsing `raw_data`, so cross-model coverage is free. Skips disconnected printers; absent sensors (no chamber on A1 / A1 Mini) just don't produce rows for that kind. **Retention.** New `printer_sensor_history_retention_days` setting (default 30, mirroring the existing `ams_history_retention_days` knob); periodic cleanup fires once every ~24 h within the recorder loop and trims rows older than the configured cutoff. **API.** `GET /printer-sensor-history/{printer_id}?hours=<1-168>&kinds=` returns one series per requested kind with `data: [{recorded_at, value, target}, ...]` + min / max / avg stats per series. `DELETE /printer-sensor-history/{printer_id}?days=` for explicit purge. Both gated behind the new explicit `PRINTER_SENSOR_HISTORY_READ` scope (separate from `AMS_HISTORY_READ` so admins can grant heater stats without granting humidity stats and vice versa — both default into the Operators + Viewers groups, mirroring the existing AMS gate). **UI surface — tiny chart icon on each heater tile.** Each heater tile in the printer card (nozzle / bed / chamber) gets a 10×10 px lucide `LineChart` icon button in its top-right corner. Click on the tile body still opens the existing target-temp control popover (unchanged); click on the icon opens the new `HeaterHistoryModal` with that kind pre-selected and `e.stopPropagation()` so the underlying control popover doesn't also fire. The read-only chamber tile (X1C / X1E / P2S — sensor-only, no M141 acceptance) gets the icon overlay too, so for the first time it has a useful interaction beyond just showing the reading. **Modal shape.** Same `var(--bg-*)` / `var(--text-*)` theme variables as `AMSHistoryModal` so it follows every background variant (neutral / warm / cool / oled / slate / forest), not just light vs dark. Kind toggle (`nozzle` / `nozzle_2` / `bed` / `chamber`, filtered by what's actually available on this printer) at top-left, time range (6h / 24h / 48h / 7d) at top-right, four stat cards (current with trend arrow + target value, average, min, max), and a recharts `LineChart` plotting current value as a solid line plus target as a dashed step-after line so you can see where the printer was tracking vs where it was asked to be. Empty / loading / error states all localised. **i18n.** 8 new keys in `printers.heaterHistory.*` (`title`, `openLabel`, `nozzle`, `nozzle2`, `bed`, `chamber`, `error`, `empty`) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW); parity check 5206 leaves per locale, no English fallback. **Tests.** 4 new backend cases in `test_printer_sensor_history.py` (per-sensor series + stats; `kinds` query filter; `hours` window clamp; DELETE removes only the targeted printer's old rows). 6 new frontend cases in `HeaterHistoryModal.test.tsx` (closed → renders nothing; open → title + printer name; current value from last point; switch kind with button; empty-series state; close button onClose). Full backend `pytest -n 30` 6226/6226 in 92 s; full frontend vitest 2176/2176; ruff clean; `npm run build` clean; ESLint clean. + - **AMS Filament Backup status + control on the printer card** — New per-printer surface that mirrors BambuStudio's "AMS Filament Backup" checkbox (the per-AMS auto-switch to a second matching spool when one runs out). Until now Bambuddy had no read or write access to the printer-side backup state; the only way to change it was via the slicer or the printer's touchscreen, and Bambuddy's "Prefer lowest remaining filament" preference was ignorant of it (see the linked Fixed entry for #1766 — the two ship together). **Backend — parse the state.** New tri-state `PrinterState.ams_filament_backup: bool | None` populated from bit 18 of the top-level `print.cfg` hex string on every push_status (`bambu_mqtt.py::_process_message` ~line 1037). New module-level helper `parse_ams_filament_backup_from_cfg()` returns `None` on absent / non-hex / non-string input so old-protocol families (A1 / A1 Mini, which emit no `cfg`) preserve today's behaviour — the tri-state default applies the dispatcher's sort, never coerces to OFF, so A1 users see zero regression. Verified against OrcaSlicer source (`DeviceManager.cpp:4961` `SetAutoRefillEnabled(get_flag_bits(cfg, 18))`) and a live H2D ON/OFF capture during this work — the cfg flips exactly between `C0340FC219` (bit 18 set, ON) and `C0340BC219` (bit 18 clear, OFF), only the fifth nibble changing. **Backend — toggle.** New `POST /printers/{id}/ams-backup?enabled=` route gated on `Permission.PRINTERS_CONTROL` calls `client.set_ams_filament_backup(enabled)` which routes through `_set_print_option("auto_switch_filament", enabled)`. The MQTT payload shape `{"print": {"command": "print_option", "auto_switch_filament": , "sequence_id": "20000"}}` was verified by capturing BambuStudio's own command on the request topic with a temporary outbound diagnostic logger — single field at a time, never bundled with other `print_option` flags, so we never clobber other state. Optimistic local state update lives inside `_set_print_option` immediately after `_client.publish(...)`. **Hold-timer guard** (`_xcam_hold_start["print_option_auto_switch_filament"]`, 3 s window, mirrors the existing xcam pattern for spaghetti / first-layer detector settings): when the user just toggled via Bambuddy's badge, the next 1-2 push_status frames may still carry the printer's PRE-toggle cfg before the firmware reflects the change — without this gate the badge would flicker ON→OFF→ON on every toggle. The hold fires only when Bambuddy itself initiated the change; Studio-side or printer-display toggles propagate immediately. **Backend — inventory-remain endpoint.** New `GET /printers/{id}/inventory-remain` route exposes the same `Map` the dispatcher uses (via the existing `_build_inventory_remain_overrides` helper), so PrintModal's client-side "Prefer Lowest Remaining Filament" sort can apply the same two-tier ordering the backend would on dispatch. Internal AND Spoolman modes both work uniformly via the existing helper's mode branch — external / VT slots excluded, negative grams clamped to `max(0.0, label - used)`. JSON-keyed-as-string convention so the wire format is clean; client coerces back to Number on receive. Permission: `Permission.PRINTERS_READ` (same as reading printer status). **REST + WS response surface.** `printer_state_to_dict` and the `PrinterStatusResponse` Pydantic schema both extended with the new field; the printer's REST `/printers/{id}` response carries `ams_filament_backup`. `state.ams_filament_backup` added to the `status_key` dedup tuple in `main.py:1101` so backup toggles trigger an immediate WS broadcast and clients see live state changes whether the toggle came from Bambuddy, BambuStudio, or the printer's touchscreen. **Frontend — printer card badge.** Small icon button in the "Filaments" section header on each printer card (`PrintersPage.tsx`), placed beside the section label so the printer-wide nature reads correctly (the cfg bit is one per printer, not per AMS unit — the original draft put it per-AMS row, which would have duplicated the same state on multi-AMS printers and looked confusing). Three states: ON = blue circular-arrow icon (`Repeat` from lucide-react) on `bg-blue-500/20`; OFF = dim icon on `bg-bambu-dark`; unknown (A1 family / no cfg yet) = "?" character on dim background, click disabled. Click on a known state toggles via the new endpoint, with optimistic update and success toast (`AMS Filament Backup enabled/disabled`). The mutation invalidates BOTH `'printerStatus'` (camelCase) and `'printer-status'` (kebab-case) cache keys — the codebase has both conventions in active use (`useFilamentMapping`-related hooks use kebab, everything else uses camelCase), so only hitting one would leave PrintModal showing stale backup state if the user toggled from the printer card while the modal was open. **i18n.** 5 new keys in the `printers.amsBackup.*` namespace (`titleOn`, `titleOff`, `titleUnknown`, `toastEnabled`, `toastDisabled`) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), no English fallback. **Tests.** 12 new backend cases — `test_bambu_mqtt_cfg_parse.py` (parser × 13: real H2D ON/OFF captures, X1C short hex, lowercase, isolated bit-18 set / clear, every malformed shape returns None safely — note: 1 case is a parametrized invalid-input set of 7 sub-cases so the test file shows 13 reported cases) and `test_bambu_mqtt.py::TestAmsFilamentBackupHoldTimer` (× 3: stale push during hold ignored, push after hold applies, same-value push during hold no-op). Two PrinterState SimpleNamespace stubs in `test_printer_offline_notification.py` and `test_printer_manager_status_broadcast.py` extended with `ams_filament_backup=None` to match the new `status_key` field; full pytest confirms no other stub needed updating. **What this does NOT do.** Cover A1 / A1 Mini: those models emit no `cfg` field in push_status so the badge shows `?` and the dispatcher's sort applies as before. Once we identify the A1-specific field (waiting on a future Discord owner with a clean ON/OFF capture) we'll populate it via a model-specific path; until then the tri-state default keeps zero regression. Affect downstream consumers of PrinterState: `mqtt_relay`, webhook routes, and Home Assistant integration enumerate fields explicitly, so adding `ams_filament_backup` doesn't change what they emit. Full backend pytest 6217/6217; full frontend vitest 2170/2170; ruff clean; `npm run build` clean; ESLint clean. ### Fixed diff --git a/README.md b/README.md index be50bfc85..8b2d6f7af 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,7 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R - Configurable drying presets per filament type (temperature & duration for AMS 2 Pro and AMS-HT) - Dual external spool support for H2D (Ext-L / Ext-R) - HMS error monitoring with history and clear errors +- **Heater history charts** — Bambuddy logs nozzle, bed, and chamber readings every minute and surfaces them via a tiny chart icon on each heater tile in the printer card. Click for a per-heater modal with current / average / min / max stats, target overlay, and a 6h / 24h / 48h / 7d time range — works on read-only chamber sensors (X1C / P2S) too. AMS humidity and temperature get the same treatment (already shipped). - Print success rates & trends - Filament usage tracking - Cost analytics & failure analysis diff --git a/backend/app/api/routes/printer_sensor_history.py b/backend/app/api/routes/printer_sensor_history.py new file mode 100644 index 000000000..57d7def57 --- /dev/null +++ b/backend/app/api/routes/printer_sensor_history.py @@ -0,0 +1,140 @@ +"""API routes for printer heater (nozzle / bed / chamber) sensor history.""" + +from datetime import datetime, timedelta, timezone + +from fastapi import APIRouter, Depends, Query +from pydantic import BaseModel +from sqlalchemy import and_, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.app.core.auth import RequirePermissionIfAuthEnabled +from backend.app.core.database import get_db +from backend.app.core.permissions import Permission +from backend.app.models.printer_sensor_history import PrinterSensorHistory +from backend.app.models.user import User + +router = APIRouter(prefix="/printer-sensor-history", tags=["printer-sensor-history"]) + +VALID_KINDS = {"nozzle", "nozzle_2", "bed", "chamber"} + + +class HeaterHistoryPoint(BaseModel): + recorded_at: datetime + value: float | None + target: float | None + + +class HeaterSeries(BaseModel): + sensor_kind: str + data: list[HeaterHistoryPoint] + min_value: float | None + max_value: float | None + avg_value: float | None + + +class PrinterSensorHistoryResponse(BaseModel): + printer_id: int + series: list[HeaterSeries] + + +@router.get("/{printer_id}", response_model=PrinterSensorHistoryResponse) +async def get_printer_sensor_history( + printer_id: int, + hours: int = Query(default=24, ge=1, le=168, description="Hours of history (1-168)"), + kinds: str | None = Query( + default=None, + description="Comma-separated list of sensor kinds (nozzle, nozzle_2, bed, chamber). All by default.", + ), + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTER_SENSOR_HISTORY_READ), +): + """Return per-sensor heater history for a printer.""" + since = datetime.now(timezone.utc) - timedelta(hours=hours) + + if kinds: + requested = {k.strip() for k in kinds.split(",") if k.strip()} + kinds_to_fetch = sorted(requested & VALID_KINDS) + else: + kinds_to_fetch = sorted(VALID_KINDS) + + series_out: list[HeaterSeries] = [] + for kind in kinds_to_fetch: + result = await db.execute( + select(PrinterSensorHistory) + .where( + and_( + PrinterSensorHistory.printer_id == printer_id, + PrinterSensorHistory.sensor_kind == kind, + PrinterSensorHistory.recorded_at >= since, + ) + ) + .order_by(PrinterSensorHistory.recorded_at) + ) + records = result.scalars().all() + + stats_result = await db.execute( + select( + func.min(PrinterSensorHistory.value).label("min_v"), + func.max(PrinterSensorHistory.value).label("max_v"), + func.avg(PrinterSensorHistory.value).label("avg_v"), + ).where( + and_( + PrinterSensorHistory.printer_id == printer_id, + PrinterSensorHistory.sensor_kind == kind, + PrinterSensorHistory.recorded_at >= since, + ) + ) + ) + stats = stats_result.one() + + series_out.append( + HeaterSeries( + sensor_kind=kind, + data=[ + HeaterHistoryPoint( + recorded_at=r.recorded_at, + value=r.value, + target=r.target, + ) + for r in records + ], + min_value=stats.min_v, + max_value=stats.max_v, + avg_value=round(stats.avg_v, 1) if stats.avg_v is not None else None, + ) + ) + + return PrinterSensorHistoryResponse(printer_id=printer_id, series=series_out) + + +@router.delete("/{printer_id}") +async def delete_old_history( + printer_id: int, + days: int = Query(default=30, ge=1, le=365, description="Delete data older than X days"), + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTER_SENSOR_HISTORY_READ), +): + """Delete old printer sensor history for a printer.""" + cutoff = datetime.now(timezone.utc) - timedelta(days=days) + + result = await db.execute( + select(func.count(PrinterSensorHistory.id)).where( + and_( + PrinterSensorHistory.printer_id == printer_id, + PrinterSensorHistory.recorded_at < cutoff, + ) + ) + ) + count = result.scalar() + + await db.execute( + PrinterSensorHistory.__table__.delete().where( + and_( + PrinterSensorHistory.printer_id == printer_id, + PrinterSensorHistory.recorded_at < cutoff, + ) + ) + ) + await db.commit() + + return {"deleted": count, "message": f"Deleted {count} records older than {days} days"} diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py index f5e5fa8e4..bad7fd6be 100644 --- a/backend/app/api/routes/settings.py +++ b/backend/app/api/routes/settings.py @@ -152,6 +152,7 @@ async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) - "ams_humidity_good", "ams_humidity_fair", "ams_history_retention_days", + "printer_sensor_history_retention_days", "ftp_retry_count", "ftp_retry_delay", "ftp_timeout", diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py index c02d9207a..527789dfb 100644 --- a/backend/app/core/auth.py +++ b/backend/app/core/auth.py @@ -88,6 +88,7 @@ _APIKEY_SCOPE_BY_PERMISSION: dict[Permission, str] = { Permission.EXTERNAL_LINKS_READ: "can_read_status", Permission.FIRMWARE_READ: "can_read_status", Permission.AMS_HISTORY_READ: "can_read_status", + Permission.PRINTER_SENSOR_HISTORY_READ: "can_read_status", Permission.STATS_READ: "can_read_status", Permission.STATS_FILTER_BY_USER: "can_read_status", Permission.SYSTEM_READ: "can_read_status", diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 3435a8afe..d846d29bd 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -193,6 +193,7 @@ async def init_db(): print_log, print_queue, printer, + printer_sensor_history, project, project_bom, settings, diff --git a/backend/app/core/permissions.py b/backend/app/core/permissions.py index 556187b40..01406ff1f 100644 --- a/backend/app/core/permissions.py +++ b/backend/app/core/permissions.py @@ -133,6 +133,7 @@ class Permission(StrEnum): # AMS History AMS_HISTORY_READ = "ams_history:read" + PRINTER_SENSOR_HISTORY_READ = "printer_sensor_history:read" # Stats/Metrics STATS_READ = "stats:read" @@ -295,6 +296,7 @@ PERMISSION_CATEGORIES = { ], "Stats & History": [ Permission.AMS_HISTORY_READ, + Permission.PRINTER_SENSOR_HISTORY_READ, Permission.STATS_READ, Permission.STATS_FILTER_BY_USER, ], @@ -445,6 +447,7 @@ DEFAULT_GROUPS = { Permission.FIRMWARE_READ.value, # Stats & History Permission.AMS_HISTORY_READ.value, + Permission.PRINTER_SENSOR_HISTORY_READ.value, Permission.STATS_READ.value, Permission.SYSTEM_READ.value, # Settings - read only @@ -476,6 +479,7 @@ DEFAULT_GROUPS = { Permission.EXTERNAL_LINKS_READ.value, Permission.FIRMWARE_READ.value, Permission.AMS_HISTORY_READ.value, + Permission.PRINTER_SENSOR_HISTORY_READ.value, Permission.STATS_READ.value, Permission.SYSTEM_READ.value, Permission.SETTINGS_READ.value, diff --git a/backend/app/main.py b/backend/app/main.py index b1368ef06..e3c1913a2 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -50,6 +50,7 @@ from backend.app.api.routes import ( pending_uploads, print_log, print_queue, + printer_sensor_history, printers, projects, settings as settings_routes, @@ -5282,6 +5283,133 @@ def stop_ams_history_recording(): logging.getLogger(__name__).info("AMS history recording stopped") +# Printer sensor history recording (nozzle / bed / chamber) +_printer_sensor_history_task: asyncio.Task | None = None +PRINTER_SENSOR_HISTORY_INTERVAL = 60 # Record every minute — heaters move faster than AMS humidity +PRINTER_SENSOR_HISTORY_RETENTION_DAYS = 30 +_printer_sensor_cleanup_counter = 0 +# Sensor kinds tracked in state.temperatures — these are the normalised keys the +# MQTT parser writes, so we don't need to handle per-model field aliases here +# (nozzle_temper / left_nozzle_temper / right_nozzle_temper / chamber_temper +# are all collapsed by services/bambu_mqtt.py before they reach this loop). +_SENSOR_KINDS = ("nozzle", "nozzle_2", "bed", "chamber") +_SENSOR_TARGET_KEYS = { + "nozzle": "nozzle_target", + "nozzle_2": "nozzle_2_target", + "bed": "bed_target", + "chamber": "chamber_target", +} + + +async def record_printer_sensor_history(): + """Background task to record nozzle / bed / chamber readings. + + Pulls from `state.temperatures` (already normalised across all printer + models by the MQTT parser) rather than re-parsing raw_data, so we get + free coverage of dual-nozzle H2D, sensor-only X1C chamber, etc. + """ + logger = logging.getLogger(__name__) + + await asyncio.sleep(10) + + while True: + try: + from backend.app.models.printer import Printer + from backend.app.models.printer_sensor_history import PrinterSensorHistory + from backend.app.models.settings import Settings + + async with async_session() as db: + result = await db.execute(select(Printer).where(Printer.is_active.is_(True))) + printers = result.scalars().all() + + recorded_count = 0 + for printer in printers: + state = printer_manager.get_status(printer.id) + if not state or not state.connected: + continue + + temps = getattr(state, "temperatures", None) or {} + if not isinstance(temps, dict): + continue + + for kind in _SENSOR_KINDS: + if kind not in temps: + continue + try: + value = float(temps[kind]) + except (ValueError, TypeError): + continue + + target_raw = temps.get(_SENSOR_TARGET_KEYS[kind]) + target_val: float | None = None + if target_raw is not None: + try: + target_val = float(target_raw) + except (ValueError, TypeError): + target_val = None + + db.add( + PrinterSensorHistory( + printer_id=printer.id, + sensor_kind=kind, + value=value, + target=target_val, + ) + ) + recorded_count += 1 + + await db.commit() + if recorded_count > 0: + logger.debug("Recorded %s printer sensor history entries", recorded_count) + + # Periodic cleanup — once every ~24h at this interval. + global _printer_sensor_cleanup_counter + _printer_sensor_cleanup_counter += 1 + cleanup_every = max(1, (24 * 60 * 60) // PRINTER_SENSOR_HISTORY_INTERVAL) + if _printer_sensor_cleanup_counter >= cleanup_every: + _printer_sensor_cleanup_counter = 0 + result = await db.execute( + select(Settings).where(Settings.key == "printer_sensor_history_retention_days") + ) + setting = result.scalar_one_or_none() + retention_days = int(setting.value) if setting else PRINTER_SENSOR_HISTORY_RETENTION_DAYS + + cutoff = datetime.utcnow() - timedelta(days=retention_days) + cleanup = await db.execute( + delete(PrinterSensorHistory).where(PrinterSensorHistory.recorded_at < cutoff) + ) + await db.commit() + if cleanup.rowcount > 0: + logger.info( + "Cleaned up %s old printer sensor history entries (older than %s days)", + cleanup.rowcount, + retention_days, + ) + + await asyncio.sleep(PRINTER_SENSOR_HISTORY_INTERVAL) + + except asyncio.CancelledError: + break + except Exception as e: + logger.warning("Printer sensor history recording failed: %s", e) + await asyncio.sleep(60) + + +def start_printer_sensor_history_recording(): + global _printer_sensor_history_task + if _printer_sensor_history_task is None: + _printer_sensor_history_task = asyncio.create_task(record_printer_sensor_history()) + logging.getLogger(__name__).info("Printer sensor history recording started") + + +def stop_printer_sensor_history_recording(): + global _printer_sensor_history_task + if _printer_sensor_history_task: + _printer_sensor_history_task.cancel() + _printer_sensor_history_task = None + logging.getLogger(__name__).info("Printer sensor history recording stopped") + + # Printer runtime tracking _runtime_tracking_task: asyncio.Task | None = None RUNTIME_TRACKING_INTERVAL = 30 # Update every 30 seconds @@ -5882,6 +6010,9 @@ async def lifespan(app: FastAPI): # Start AMS history recording start_ams_history_recording() + # Start printer sensor (nozzle / bed / chamber) history recording + start_printer_sensor_history_recording() + # Start printer runtime tracking start_runtime_tracking() @@ -5928,6 +6059,7 @@ async def lifespan(app: FastAPI): archive_purge_service.stop_scheduler() obico_detection_service.stop() stop_ams_history_recording() + stop_printer_sensor_history_recording() stop_runtime_tracking() stop_spoolbuddy_watchdog() stop_camera_cleanup() @@ -6406,6 +6538,7 @@ app.include_router(makerworld.router, prefix=app_settings.api_prefix) app.include_router(api_keys.router, prefix=app_settings.api_prefix) app.include_router(webhook.router, prefix=app_settings.api_prefix) app.include_router(ams_history.router, prefix=app_settings.api_prefix) +app.include_router(printer_sensor_history.router, prefix=app_settings.api_prefix) app.include_router(system.router, prefix=app_settings.api_prefix) app.include_router(support.router, prefix=app_settings.api_prefix) app.include_router(websocket.router, prefix=app_settings.api_prefix) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 2ea5efb99..140fd4d96 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -20,6 +20,7 @@ from backend.app.models.orca_base_cache import OrcaBaseProfile from backend.app.models.pending_upload import PendingUpload from backend.app.models.print_batch import PrintBatch from backend.app.models.printer import Printer +from backend.app.models.printer_sensor_history import PrinterSensorHistory from backend.app.models.project import Project from backend.app.models.settings import Settings from backend.app.models.smart_plug import SmartPlug @@ -51,6 +52,7 @@ __all__ = [ "Project", "APIKey", "AMSSensorHistory", + "PrinterSensorHistory", "AmsLabel", "PendingUpload", "PrintBatch", diff --git a/backend/app/models/printer.py b/backend/app/models/printer.py index 442b58be3..aaef4c8b5 100644 --- a/backend/app/models/printer.py +++ b/backend/app/models/printer.py @@ -58,6 +58,9 @@ class Printer(Base): ) kprofile_notes: Mapped[list["KProfileNote"]] = relationship(back_populates="printer", cascade="all, delete-orphan") ams_history: Mapped[list["AMSSensorHistory"]] = relationship(back_populates="printer", cascade="all, delete-orphan") + sensor_history: Mapped[list["PrinterSensorHistory"]] = relationship( + back_populates="printer", cascade="all, delete-orphan" + ) from backend.app.models.ams_history import AMSSensorHistory # noqa: E402 @@ -65,4 +68,5 @@ from backend.app.models.archive import PrintArchive # noqa: E402 from backend.app.models.kprofile_note import KProfileNote # noqa: E402 from backend.app.models.maintenance import PrinterMaintenance # noqa: E402 from backend.app.models.notification import NotificationProvider # noqa: E402 +from backend.app.models.printer_sensor_history import PrinterSensorHistory # noqa: E402 from backend.app.models.smart_plug import SmartPlug # noqa: E402 diff --git a/backend/app/models/printer_sensor_history.py b/backend/app/models/printer_sensor_history.py new file mode 100644 index 000000000..3de21d65d --- /dev/null +++ b/backend/app/models/printer_sensor_history.py @@ -0,0 +1,40 @@ +from datetime import datetime + +from sqlalchemy import DateTime, Float, ForeignKey, Index, String, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from backend.app.core.database import Base + + +class PrinterSensorHistory(Base): + """Historical heater readings (nozzle / nozzle_2 / bed / chamber). + + Parallel to AMSSensorHistory, but per-(printer, sensor_kind) rather + than per-(printer, ams_id). Sensor counts vary by model (single vs + dual nozzle, presence of chamber heater), so a long-format row per + sensor reads cleanly and leaves room for future kinds (cpu, motor) + without another migration. + """ + + __tablename__ = "printer_sensor_history" + + id: Mapped[int] = mapped_column(primary_key=True) + printer_id: Mapped[int] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE")) + sensor_kind: Mapped[str] = mapped_column(String(32)) # nozzle | nozzle_2 | bed | chamber + value: Mapped[float | None] = mapped_column(Float) # current temperature, Celsius + target: Mapped[float | None] = mapped_column(Float) # target temperature when set, Celsius + recorded_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), index=True) + + __table_args__ = ( + Index( + "ix_printer_sensor_history_printer_kind_time", + "printer_id", + "sensor_kind", + "recorded_at", + ), + ) + + printer: Mapped["Printer"] = relationship(back_populates="sensor_history") + + +from backend.app.models.printer import Printer # noqa: E402 diff --git a/backend/app/schemas/settings.py b/backend/app/schemas/settings.py index 46ee15f91..441a0bf4a 100644 --- a/backend/app/schemas/settings.py +++ b/backend/app/schemas/settings.py @@ -72,6 +72,9 @@ class AppSettings(BaseModel): default=35.0, description="Temperature threshold for fair (orange): <= this value, > is red" ) ams_history_retention_days: int = Field(default=30, description="Number of days to keep AMS sensor history data") + printer_sensor_history_retention_days: int = Field( + default=30, description="Number of days to keep printer heater history data (nozzle / bed / chamber)" + ) # Queue auto-drying settings queue_drying_enabled: bool = Field( @@ -405,6 +408,7 @@ class AppSettingsUpdate(BaseModel): ams_temp_good: float | None = None ams_temp_fair: float | None = None ams_history_retention_days: int | None = None + printer_sensor_history_retention_days: int | None = None queue_drying_enabled: bool | None = None queue_drying_block: bool | None = None ambient_drying_enabled: bool | None = None diff --git a/backend/tests/unit/test_printer_sensor_history.py b/backend/tests/unit/test_printer_sensor_history.py new file mode 100644 index 000000000..717f48f0c --- /dev/null +++ b/backend/tests/unit/test_printer_sensor_history.py @@ -0,0 +1,142 @@ +"""Tests for printer heater (nozzle / bed / chamber) sensor history.""" + +from datetime import datetime, timedelta, timezone + +import pytest +from httpx import AsyncClient +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.app.models.printer import Printer +from backend.app.models.printer_sensor_history import PrinterSensorHistory + + +@pytest.mark.asyncio +async def test_get_returns_per_sensor_series(async_client: AsyncClient, db_session: AsyncSession): + """Each sensor_kind returns its own series with stats.""" + printer = Printer(name="X1C", serial_number="X1C-TEST-001", ip_address="10.0.0.10", access_code="12345678") + db_session.add(printer) + await db_session.commit() + await db_session.refresh(printer) + + base = datetime.now(timezone.utc) - timedelta(hours=1) + samples = [ + ("nozzle", 210.0, 220.0, 0), + ("nozzle", 215.0, 220.0, 5), + ("nozzle", 220.0, 220.0, 10), + ("bed", 55.0, 60.0, 0), + ("bed", 60.0, 60.0, 5), + ("chamber", 38.0, 40.0, 0), + ] + for kind, value, target, minutes in samples: + db_session.add( + PrinterSensorHistory( + printer_id=printer.id, + sensor_kind=kind, + value=value, + target=target, + recorded_at=base + timedelta(minutes=minutes), + ) + ) + await db_session.commit() + + response = await async_client.get(f"/api/v1/printer-sensor-history/{printer.id}?hours=24") + assert response.status_code == 200 + body = response.json() + assert body["printer_id"] == printer.id + series_by_kind = {s["sensor_kind"]: s for s in body["series"]} + + assert series_by_kind["nozzle"]["min_value"] == 210.0 + assert series_by_kind["nozzle"]["max_value"] == 220.0 + assert series_by_kind["nozzle"]["avg_value"] == pytest.approx(215.0, rel=0.01) + assert len(series_by_kind["nozzle"]["data"]) == 3 + + assert series_by_kind["bed"]["min_value"] == 55.0 + assert series_by_kind["bed"]["max_value"] == 60.0 + assert len(series_by_kind["bed"]["data"]) == 2 + + assert series_by_kind["chamber"]["max_value"] == 38.0 + # nozzle_2 wasn't recorded — series present but empty + assert series_by_kind["nozzle_2"]["data"] == [] + assert series_by_kind["nozzle_2"]["min_value"] is None + + +@pytest.mark.asyncio +async def test_get_filters_by_kinds_query(async_client: AsyncClient, db_session: AsyncSession): + """`kinds=bed,chamber` only returns those series.""" + printer = Printer(name="X1C", serial_number="X1C-TEST-002", ip_address="10.0.0.11", access_code="12345678") + db_session.add(printer) + await db_session.commit() + await db_session.refresh(printer) + + db_session.add(PrinterSensorHistory(printer_id=printer.id, sensor_kind="bed", value=60.0, target=60.0)) + await db_session.commit() + + response = await async_client.get(f"/api/v1/printer-sensor-history/{printer.id}?hours=24&kinds=bed,chamber") + assert response.status_code == 200 + kinds_returned = {s["sensor_kind"] for s in response.json()["series"]} + assert kinds_returned == {"bed", "chamber"} + + +@pytest.mark.asyncio +async def test_get_clamps_to_hours_window(async_client: AsyncClient, db_session: AsyncSession): + """Rows older than the requested window are excluded.""" + printer = Printer(name="X1C", serial_number="X1C-TEST-003", ip_address="10.0.0.12", access_code="12345678") + db_session.add(printer) + await db_session.commit() + await db_session.refresh(printer) + + now = datetime.now(timezone.utc) + # One inside the window, one outside. + db_session.add( + PrinterSensorHistory( + printer_id=printer.id, + sensor_kind="bed", + value=60.0, + target=60.0, + recorded_at=now - timedelta(minutes=30), + ) + ) + db_session.add( + PrinterSensorHistory( + printer_id=printer.id, + sensor_kind="bed", + value=55.0, + target=60.0, + recorded_at=now - timedelta(hours=10), + ) + ) + await db_session.commit() + + response = await async_client.get(f"/api/v1/printer-sensor-history/{printer.id}?hours=1") + body = response.json() + bed_series = next(s for s in body["series"] if s["sensor_kind"] == "bed") + assert len(bed_series["data"]) == 1 + assert bed_series["data"][0]["value"] == 60.0 + + +@pytest.mark.asyncio +async def test_delete_removes_old_rows(async_client: AsyncClient, db_session: AsyncSession): + """DELETE removes rows older than `days` for the given printer only.""" + keep_printer = Printer(name="Keep", serial_number="KEEP-001", ip_address="10.0.0.20", access_code="12345678") + other_printer = Printer(name="Other", serial_number="OTHER-001", ip_address="10.0.0.21", access_code="12345678") + db_session.add_all([keep_printer, other_printer]) + await db_session.commit() + await db_session.refresh(keep_printer) + await db_session.refresh(other_printer) + + now = datetime.now(timezone.utc) + old = now - timedelta(days=40) + db_session.add(PrinterSensorHistory(printer_id=keep_printer.id, sensor_kind="bed", value=60.0, recorded_at=old)) + db_session.add(PrinterSensorHistory(printer_id=keep_printer.id, sensor_kind="bed", value=60.0, recorded_at=now)) + db_session.add(PrinterSensorHistory(printer_id=other_printer.id, sensor_kind="bed", value=60.0, recorded_at=old)) + await db_session.commit() + + response = await async_client.delete(f"/api/v1/printer-sensor-history/{keep_printer.id}?days=30") + assert response.status_code == 200 + assert response.json()["deleted"] == 1 + + # other printer's old row untouched. + rows = (await db_session.execute(select(PrinterSensorHistory))).scalars().all() + kinds_left = sorted((r.printer_id, r.value) for r in rows) + assert kinds_left == sorted([(keep_printer.id, 60.0), (other_printer.id, 60.0)]) diff --git a/frontend/src/__tests__/components/HeaterHistoryModal.test.tsx b/frontend/src/__tests__/components/HeaterHistoryModal.test.tsx new file mode 100644 index 000000000..e79974482 --- /dev/null +++ b/frontend/src/__tests__/components/HeaterHistoryModal.test.tsx @@ -0,0 +1,132 @@ +/** + * Tests for the HeaterHistoryModal component. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { screen, waitFor, fireEvent } from '@testing-library/react'; +import { render } from '../utils'; +import { HeaterHistoryModal } from '../../components/HeaterHistoryModal'; +import { api } from '../../api/client'; + +vi.mock('../../api/client', () => ({ + api: { + getPrinterSensorHistory: vi.fn(), + getSettings: vi.fn().mockResolvedValue({}), + updateSettings: vi.fn().mockResolvedValue({}), + }, +})); + +vi.mock('recharts', () => ({ + LineChart: ({ children }: { children: React.ReactNode }) =>
{children}
, + Line: () => null, + XAxis: () => null, + YAxis: () => null, + CartesianGrid: () => null, + Tooltip: () => null, + ResponsiveContainer: ({ children }: { children: React.ReactNode }) =>
{children}
, + Legend: () => null, +})); + +const mockResponse = { + printer_id: 1, + series: [ + { + sensor_kind: 'nozzle' as const, + data: [ + { recorded_at: '2024-12-11T10:00:00Z', value: 210, target: 220 }, + { recorded_at: '2024-12-11T10:05:00Z', value: 215, target: 220 }, + { recorded_at: '2024-12-11T10:10:00Z', value: 220, target: 220 }, + ], + min_value: 210, + max_value: 220, + avg_value: 215, + }, + { + sensor_kind: 'bed' as const, + data: [ + { recorded_at: '2024-12-11T10:00:00Z', value: 55, target: 60 }, + { recorded_at: '2024-12-11T10:05:00Z', value: 60, target: 60 }, + ], + min_value: 55, + max_value: 60, + avg_value: 57.5, + }, + { + sensor_kind: 'chamber' as const, + data: [], + min_value: null, + max_value: null, + avg_value: null, + }, + ], +}; + +const defaultProps = { + isOpen: true, + onClose: vi.fn(), + printerId: 1, + printerName: 'Test Printer', + initialKind: 'nozzle' as const, + availableKinds: ['nozzle', 'bed', 'chamber'] as const, +}; + +describe('HeaterHistoryModal', () => { + beforeEach(() => { + vi.clearAllMocks(); + (api.getPrinterSensorHistory as ReturnType).mockResolvedValue(mockResponse); + }); + + it('renders nothing when closed', () => { + render(); + expect(screen.queryByText(/Heater History/i)).not.toBeInTheDocument(); + }); + + it('renders title + printer name when open', async () => { + render(); + await waitFor(() => { + expect(screen.getByText(/Heater History/i)).toBeInTheDocument(); + expect(screen.getByText('Test Printer')).toBeInTheDocument(); + }); + }); + + it('shows current nozzle value from last data point', async () => { + render(); + await waitFor(() => { + // current=220, target=220, max=220 — multiple matches are expected. + expect(screen.getAllByText(/220°C/).length).toBeGreaterThan(0); + }); + }); + + it('switches series when bed mode button clicked', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('Test Printer')).toBeInTheDocument(); + }); + + const bedButtons = screen.getAllByText(/^Bed$/); + fireEvent.click(bedButtons[0]); + + await waitFor(() => { + // 60 = current bed value (and max, and target). + expect(screen.getAllByText(/60°C/).length).toBeGreaterThan(0); + }); + }); + + it('shows empty state when series has no data', async () => { + render(); + await waitFor(() => { + expect(screen.getByText(/No data recorded yet/i)).toBeInTheDocument(); + }); + }); + + it('close button triggers onClose', async () => { + const onClose = vi.fn(); + render(); + await waitFor(() => { + expect(screen.getByText('Test Printer')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByLabelText(/Close/i)); + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 3354258f7..24b674a47 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -5724,6 +5724,13 @@ export const api = { getAMSHistory: (printerId: number, amsId: number, hours = 24) => request(`/ams-history/${printerId}/${amsId}?hours=${hours}`), + // Printer heater (nozzle / bed / chamber) sensor history + getPrinterSensorHistory: (printerId: number, hours = 24, kinds?: string[]) => { + const params = new URLSearchParams({ hours: String(hours) }); + if (kinds && kinds.length > 0) params.set('kinds', kinds.join(',')); + return request(`/printer-sensor-history/${printerId}?${params.toString()}`); + }, + // System Info getSystemInfo: () => request('/system/info'), getSystemHealth: () => request('/system/health'), @@ -6159,6 +6166,27 @@ export interface AMSHistoryResponse { avg_temperature: number | null; } +export type HeaterSensorKind = 'nozzle' | 'nozzle_2' | 'bed' | 'chamber'; + +export interface HeaterHistoryPoint { + recorded_at: string; + value: number | null; + target: number | null; +} + +export interface HeaterSeries { + sensor_kind: HeaterSensorKind; + data: HeaterHistoryPoint[]; + min_value: number | null; + max_value: number | null; + avg_value: number | null; +} + +export interface PrinterSensorHistoryResponse { + printer_id: number; + series: HeaterSeries[]; +} + // System Info types export interface SystemInfo { app: { diff --git a/frontend/src/components/HeaterHistoryModal.tsx b/frontend/src/components/HeaterHistoryModal.tsx new file mode 100644 index 000000000..e463149ba --- /dev/null +++ b/frontend/src/components/HeaterHistoryModal.tsx @@ -0,0 +1,343 @@ +import { useState, useEffect } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { X, Flame, Square, Box, TrendingUp, TrendingDown, Minus } from 'lucide-react'; +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Legend, +} from 'recharts'; +import { api, type HeaterSensorKind, type PrinterSensorHistoryResponse } from '../api/client'; +import { parseUTCDate, applyTimeFormat, type TimeFormat } from '../utils/date'; +import { useTranslation } from 'react-i18next'; + +interface HeaterHistoryModalProps { + isOpen: boolean; + onClose: () => void; + printerId: number; + printerName: string; + initialKind?: HeaterSensorKind; + availableKinds?: HeaterSensorKind[]; +} + +type TimeRange = '6h' | '24h' | '48h' | '7d'; + +const TIME_RANGES: { value: TimeRange; label: string; hours: number }[] = [ + { value: '6h', label: '6h', hours: 6 }, + { value: '24h', label: '24h', hours: 24 }, + { value: '48h', label: '48h', hours: 48 }, + { value: '7d', label: '7d', hours: 168 }, +]; + +const KIND_COLORS: Record = { + nozzle: '#fb923c', + nozzle_2: '#f97316', + bed: '#60a5fa', + chamber: '#34d399', +}; + +const KIND_TARGET_COLORS: Record = { + nozzle: '#fed7aa', + nozzle_2: '#fdba74', + bed: '#bfdbfe', + chamber: '#a7f3d0', +}; + +export function HeaterHistoryModal({ + isOpen, + onClose, + printerId, + printerName, + initialKind = 'nozzle', + availableKinds = ['nozzle', 'bed', 'chamber'], +}: HeaterHistoryModalProps) { + const { t } = useTranslation(); + const [timeRange, setTimeRange] = useState('24h'); + const [kind, setKind] = useState(initialKind); + + useEffect(() => { + setKind(initialKind); + }, [initialKind]); + + const { data: settings } = useQuery({ + queryKey: ['settings'], + queryFn: api.getSettings, + }); + + const timeFormat: TimeFormat = settings?.time_format || 'system'; + + useEffect(() => { + if (!isOpen) return; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isOpen, onClose]); + + const hours = TIME_RANGES.find(r => r.value === timeRange)?.hours || 24; + + const { data, isLoading, error } = useQuery({ + queryKey: ['printer-sensor-history', printerId, hours, availableKinds.join(',')], + queryFn: () => api.getPrinterSensorHistory(printerId, hours, availableKinds), + enabled: isOpen, + refetchInterval: 60000, + }); + + if (!isOpen) return null; + + const series = data?.series.find(s => s.sensor_kind === kind); + + const rawPoints = (series?.data || []).map(p => { + const date = parseUTCDate(p.recorded_at) || new Date(); + return { + time: date.getTime(), + value: p.value, + target: p.target, + }; + }); + + const domainStart = Date.now() - hours * 60 * 60 * 1000; + const domainEnd = Date.now(); + const chartData = [...rawPoints]; + if (chartData.length > 0) { + const first = chartData[0]; + if (first.time > domainStart) { + chartData.unshift({ ...first, time: domainStart }); + } + const last = chartData[chartData.length - 1]; + if (last.time < domainEnd) { + chartData.push({ ...last, time: domainEnd }); + } + } + + const lastPoint = chartData[chartData.length - 1]; + const currentValue = lastPoint?.value; + const currentTarget = lastPoint?.target; + + const getTrend = (values: (number | null)[]) => { + const filtered = values.filter((v): v is number => v != null); + if (filtered.length < 4) return 'stable'; + const firstQuarter = filtered.slice(0, Math.floor(filtered.length / 4)); + const lastQuarter = filtered.slice(-Math.floor(filtered.length / 4)); + const firstAvg = firstQuarter.reduce((a, b) => a + b, 0) / firstQuarter.length; + const lastAvg = lastQuarter.reduce((a, b) => a + b, 0) / lastQuarter.length; + const diff = lastAvg - firstAvg; + if (Math.abs(diff) < 2) return 'stable'; + return diff > 0 ? 'up' : 'down'; + }; + + const trend = getTrend(chartData.map(d => d.value)); + + const TrendIcon = ({ trend }: { trend: string }) => { + if (trend === 'up') return ; + if (trend === 'down') return ; + return ; + }; + + const modalBg = 'var(--bg-secondary)'; + const cardBg = 'var(--bg-primary)'; + const borderColor = 'var(--border-color)'; + const textPrimary = 'var(--text-primary)'; + const textSecondary = 'var(--text-secondary)'; + const axisColor = 'var(--text-muted)'; + + const kindLabel = (k: HeaterSensorKind) => { + switch (k) { + case 'nozzle': + return t('printers.heaterHistory.nozzle', 'Nozzle'); + case 'nozzle_2': + return t('printers.heaterHistory.nozzle2', 'Nozzle 2'); + case 'bed': + return t('printers.heaterHistory.bed', 'Bed'); + case 'chamber': + return t('printers.heaterHistory.chamber', 'Chamber'); + } + }; + + const KindIcon = ({ k }: { k: HeaterSensorKind }) => { + if (k === 'nozzle' || k === 'nozzle_2') return ; + if (k === 'bed') return ; + return ; + }; + + return ( +
+
e.stopPropagation()} + > +
+
+

+ {t('printers.heaterHistory.title', 'Heater History')} +

+

{printerName}

+
+ +
+ +
+
+
+ {availableKinds.map(k => ( + + ))} +
+ +
+ {TIME_RANGES.map(range => ( + + ))} +
+
+ +
+
+

{t('common.current', 'Current')}

+
+

+ {currentValue != null ? `${Math.round(currentValue)}°C` : '—'} +

+ +
+ {currentTarget != null && currentTarget > 0 && ( +

+ {t('common.target', 'Target')}: {Math.round(currentTarget)}°C +

+ )} +
+
+

{t('common.average', 'Average')}

+

+ {series?.avg_value != null ? `${series.avg_value}°C` : '—'} +

+
+
+

{t('common.min', 'Min')}

+

+ {series?.min_value != null ? `${Math.round(series.min_value)}°C` : '—'} +

+
+
+

{t('common.max', 'Max')}

+

+ {series?.max_value != null ? `${Math.round(series.max_value)}°C` : '—'} +

+
+
+ +
+ {isLoading ? ( +
+ {t('common.loading', 'Loading...')} +
+ ) : error ? ( +
+ {t('printers.heaterHistory.error', 'Failed to load history')} +
+ ) : chartData.length === 0 ? ( +
+ {t('printers.heaterHistory.empty', 'No data recorded yet')} +
+ ) : ( + + + + + new Date(ts).toLocaleTimeString( + [], + applyTimeFormat({ hour: '2-digit', minute: '2-digit' }, timeFormat), + ) + } + stroke={axisColor} + fontSize={11} + /> + `${Math.round(v)}°`} + /> + + new Date(ts as number).toLocaleString( + undefined, + applyTimeFormat( + { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }, + timeFormat, + ), + ) + } + formatter={(value) => (value != null ? `${Math.round(Number(value))}°C` : '—')} + /> + + + + + + )} +
+
+
+
+ ); +} diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index 0e4d0ff47..e20955644 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -158,6 +158,16 @@ export default { bed: 'Druckbett', chamber: 'Kammer', }, + heaterHistory: { + title: 'Heizungsverlauf', + openLabel: 'Heizungsverlauf anzeigen', + nozzle: 'Düse', + nozzle2: 'Düse 2', + bed: 'Druckbett', + chamber: 'Kammer', + error: 'Verlauf konnte nicht geladen werden', + empty: 'Noch keine Daten aufgezeichnet', + }, progress: '{{percent}}% abgeschlossen', timeRemaining: 'Noch {{time}}', deleteConfirm: 'Möchten Sie "{{name}}" wirklich löschen?', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 23c4d1bf3..3c79e78e3 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -158,6 +158,16 @@ export default { bed: 'Bed', chamber: 'Chamber', }, + heaterHistory: { + title: 'Heater History', + openLabel: 'View heater history', + nozzle: 'Nozzle', + nozzle2: 'Nozzle 2', + bed: 'Bed', + chamber: 'Chamber', + error: 'Failed to load history', + empty: 'No data recorded yet', + }, progress: '{{percent}}% complete', timeRemaining: '{{time}} remaining', deleteConfirm: 'Are you sure you want to delete "{{name}}"?', diff --git a/frontend/src/i18n/locales/es.ts b/frontend/src/i18n/locales/es.ts index fb43b4c6c..c779bcda0 100644 --- a/frontend/src/i18n/locales/es.ts +++ b/frontend/src/i18n/locales/es.ts @@ -158,6 +158,16 @@ export default { bed: 'Cama', chamber: 'Cámara', }, + heaterHistory: { + title: 'Historial de calentadores', + openLabel: 'Ver historial de calentadores', + nozzle: 'Boquilla', + nozzle2: 'Boquilla 2', + bed: 'Cama', + chamber: 'Cámara', + error: 'No se pudo cargar el historial', + empty: 'Aún no hay datos registrados', + }, progress: '{{percent}}% completado', timeRemaining: '{{time}} restante', deleteConfirm: '¿Está seguro de que desea eliminar "{{name}}"?', diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index e4de4c046..ed3378eef 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -158,6 +158,16 @@ export default { bed: 'Plateau', chamber: 'Chambre', }, + heaterHistory: { + title: 'Historique des chauffages', + openLabel: 'Voir l\'historique des chauffages', + nozzle: 'Buse', + nozzle2: 'Buse 2', + bed: 'Plateau', + chamber: 'Chambre', + error: 'Échec du chargement de l\'historique', + empty: 'Aucune donnée enregistrée', + }, progress: '{{percent}}% terminé', timeRemaining: '{{time}} restant', deleteConfirm: 'Êtes-vous sûr de vouloir supprimer "{{name}}" ?', diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index bf7e0156e..fee6d5742 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/frontend/src/i18n/locales/it.ts @@ -158,6 +158,16 @@ export default { bed: 'Piatto', chamber: 'Camera', }, + heaterHistory: { + title: 'Storico riscaldatori', + openLabel: 'Visualizza storico riscaldatori', + nozzle: 'Ugello', + nozzle2: 'Ugello 2', + bed: 'Piatto', + chamber: 'Camera', + error: 'Caricamento storico fallito', + empty: 'Nessun dato registrato', + }, progress: '{{percent}}% completato', timeRemaining: '{{time}} rimanente', deleteConfirm: 'Sei sicuro di eliminare "{{name}}"?', diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index 1b0b7da70..373fdb649 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -157,6 +157,16 @@ export default { bed: 'ベッド', chamber: 'チャンバー', }, + heaterHistory: { + title: 'ヒーター履歴', + openLabel: 'ヒーター履歴を表示', + nozzle: 'ノズル', + nozzle2: 'ノズル 2', + bed: 'ベッド', + chamber: 'チャンバー', + error: '履歴を読み込めませんでした', + empty: 'まだデータが記録されていません', + }, progress: '{{percent}}% 完了', timeRemaining: '残り {{time}}', deleteConfirm: '「{{name}}」を削除しますか?', diff --git a/frontend/src/i18n/locales/ko.ts b/frontend/src/i18n/locales/ko.ts index 7f2f79773..1b3330a51 100644 --- a/frontend/src/i18n/locales/ko.ts +++ b/frontend/src/i18n/locales/ko.ts @@ -147,6 +147,16 @@ export default { bed: '베드', chamber: '챔버' }, + heaterHistory: { + title: '히터 기록', + openLabel: '히터 기록 보기', + nozzle: '노즐', + nozzle2: '노즐 2', + bed: '베드', + chamber: '챔버', + error: '기록을 불러올 수 없습니다', + empty: '아직 기록된 데이터가 없습니다' + }, progress: '{{percent}}% 완료', timeRemaining: '{{time}} 남음', deleteConfirm: '"{{name}}"을(를) 삭제하시겠습니까?', diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index 439f9321a..4d1d9b0f3 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/frontend/src/i18n/locales/pt-BR.ts @@ -158,6 +158,16 @@ export default { bed: 'Cama', chamber: 'Câmara', }, + heaterHistory: { + title: 'Histórico de aquecedores', + openLabel: 'Ver histórico de aquecedores', + nozzle: 'Bico', + nozzle2: 'Bico 2', + bed: 'Cama', + chamber: 'Câmara', + error: 'Falha ao carregar histórico', + empty: 'Nenhum dado registrado ainda', + }, progress: '{{percent}}% concluído', timeRemaining: '{{time}} restante', deleteConfirm: 'Tem certeza de que deseja excluir "{{name}}"?', diff --git a/frontend/src/i18n/locales/tr.ts b/frontend/src/i18n/locales/tr.ts index 493762371..a92c0550f 100644 --- a/frontend/src/i18n/locales/tr.ts +++ b/frontend/src/i18n/locales/tr.ts @@ -158,6 +158,16 @@ export default { bed: 'Tabla', chamber: 'Hazne', }, + heaterHistory: { + title: 'Isıtıcı Geçmişi', + openLabel: 'Isıtıcı geçmişini görüntüle', + nozzle: 'Nozul', + nozzle2: 'Nozul 2', + bed: 'Tabla', + chamber: 'Hazne', + error: 'Geçmiş yüklenemedi', + empty: 'Henüz veri kaydedilmedi', + }, progress: '%{{percent}} tamamlandı', timeRemaining: '{{time}} kaldı', deleteConfirm: '"{{name}}" silmek istediğinizden emin misiniz?', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index acdecd578..de96d6319 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -158,6 +158,16 @@ export default { bed: '热床', chamber: '腔室', }, + heaterHistory: { + title: '加热历史', + openLabel: '查看加热历史', + nozzle: '喷嘴', + nozzle2: '喷嘴 2', + bed: '热床', + chamber: '腔室', + error: '加载历史失败', + empty: '尚未记录数据', + }, progress: '{{percent}}% 完成', timeRemaining: '剩余 {{time}}', deleteConfirm: '确定要删除"{{name}}"吗?', diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts index da5e9a915..c467d692d 100644 --- a/frontend/src/i18n/locales/zh-TW.ts +++ b/frontend/src/i18n/locales/zh-TW.ts @@ -158,6 +158,16 @@ export default { bed: '熱床', chamber: '腔室', }, + heaterHistory: { + title: '加熱歷史', + openLabel: '查看加熱歷史', + nozzle: '噴嘴', + nozzle2: '噴嘴 2', + bed: '熱床', + chamber: '腔室', + error: '載入歷史失敗', + empty: '尚未記錄資料', + }, progress: '{{percent}}% 完成', timeRemaining: '剩餘 {{time}}', deleteConfirm: '確定要刪除"{{name}}"嗎?', diff --git a/frontend/src/pages/PrintersPage.tsx b/frontend/src/pages/PrintersPage.tsx index 25655dd9f..97b765bcd 100644 --- a/frontend/src/pages/PrintersPage.tsx +++ b/frontend/src/pages/PrintersPage.tsx @@ -81,6 +81,7 @@ import { MoreHorizontal, SlidersHorizontal, Stethoscope, + LineChart as LineChartIcon, } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; @@ -97,6 +98,8 @@ import { MQTTDebugModal } from '../components/MQTTDebugModal'; import { HMSErrorModal, filterKnownHMSErrors } from '../components/HMSErrorModal'; import { PrinterQueueWidget } from '../components/PrinterQueueWidget'; import { AMSHistoryModal } from '../components/AMSHistoryModal'; +import { HeaterHistoryModal } from '../components/HeaterHistoryModal'; +import type { HeaterSensorKind } from '../api/client'; import { FilamentHoverCard, EmptySlotHoverCard } from '../components/FilamentHoverCard'; import { LinkSpoolModal } from '../components/LinkSpoolModal'; import { AssignSpoolModal } from '../components/AssignSpoolModal'; @@ -1832,6 +1835,10 @@ function PrinterCard({ amsLabel: string; mode: 'humidity' | 'temperature'; } | null>(null); + const [heaterHistoryModal, setHeaterHistoryModal] = useState<{ + initialKind: HeaterSensorKind; + availableKinds: HeaterSensorKind[]; + } | null>(null); const [linkSpoolModal, setLinkSpoolModal] = useState<{ tagUid: string; trayUuid: string; @@ -3549,6 +3556,13 @@ function PrinterCard({ const bedHeating = status.temperatures.bed_heating || false; const chamberHeating = status.temperatures.chamber_heating || false; const isDualNozzle = printer.nozzle_count === 2 || status.temperatures.nozzle_2 !== undefined; + const availableHeaterKinds: HeaterSensorKind[] = (() => { + const kinds: HeaterSensorKind[] = ['nozzle']; + if (status.temperatures.nozzle_2 !== undefined) kinds.push('nozzle_2'); + kinds.push('bed'); + if (status.temperatures.chamber !== undefined) kinds.push('chamber'); + return kinds; + })(); // active_extruder: 0=right, 1=left const activeNozzle = status.active_extruder === 1 ? 'L' : 'R'; // Extended nozzle data from nozzle_rack (H2 series: wear, serial, max_temp, etc.) @@ -3595,6 +3609,17 @@ function PrinterCard({ title={statusControlTitle} onClick={() => canUseStatusControls && setStatusControlMenu(statusControlMenu === 'nozzle-temp' ? null : 'nozzle-temp')} > + {status.temperatures.nozzle_2 !== undefined ? ( <> @@ -3670,6 +3695,17 @@ function PrinterCard({ title={statusControlTitle} onClick={() => canUseStatusControls && setStatusControlMenu(statusControlMenu === 'bed-temp' ? null : 'bed-temp')} > +

{t('printers.temperatures.bed')}

@@ -3696,12 +3732,23 @@ function PrinterCard({

canUseStatusControls && setStatusControlMenu(statusControlMenu === 'chamber-temp' ? null : 'chamber-temp') : undefined} > +

{t('printers.temperatures.chamber')}

@@ -5940,6 +5987,18 @@ function PrinterCard({ /> )} + {/* Heater History Modal (nozzle / bed / chamber) */} + {heaterHistoryModal && ( + setHeaterHistoryModal(null)} + printerId={printer.id} + printerName={printer.name} + initialKind={heaterHistoryModal.initialKind} + availableKinds={heaterHistoryModal.availableKinds} + /> + )} + {/* Link Spool Modal */} {linkSpoolModal && (