Explorar el Código

feat(heater-history): track nozzle / bed / chamber readings + per-tile chart-icon overlay opens history modal

  New PrinterSensorHistory table + 60s recorder + GET/DELETE /printer-sensor-history
  route gated behind a new PRINTER_SENSOR_HISTORY_READ scope (separate from AMS). UI
  adds a 10x10 LineChart icon on each heater tile - click body opens the existing
  target-temp popover unchanged, click icon opens a HeaterHistoryModal mirroring the
  AMSHistoryModal shape (kind toggle + 6h/24h/48h/7d range + current/avg/min/max +
  recharts line for value + dashed target). Read-only X1C/P2S chamber tile finally
  gets an interaction. Retention configurable via printer_sensor_history_retention_days
  (default 30, sibling of ams_history_retention_days). 8 new i18n keys translated in
  all 11 locales, parity green. 4 backend + 6 frontend tests added; full pytest -n 30
  6226/6226, vitest 2176/2176, ruff/eslint/build all clean.
maziggy hace 2 meses
padre
commit
090c180ebf

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
CHANGELOG.md


+ 1 - 0
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

+ 140 - 0
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"}

+ 1 - 0
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",

+ 1 - 0
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",

+ 1 - 0
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,

+ 4 - 0
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,

+ 133 - 0
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)

+ 2 - 0
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",

+ 4 - 0
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

+ 40 - 0
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

+ 4 - 0
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

+ 142 - 0
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)])

+ 132 - 0
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 }) => <div data-testid="line-chart">{children}</div>,
+  Line: () => null,
+  XAxis: () => null,
+  YAxis: () => null,
+  CartesianGrid: () => null,
+  Tooltip: () => null,
+  ResponsiveContainer: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
+  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<typeof vi.fn>).mockResolvedValue(mockResponse);
+  });
+
+  it('renders nothing when closed', () => {
+    render(<HeaterHistoryModal {...defaultProps} isOpen={false} />);
+    expect(screen.queryByText(/Heater History/i)).not.toBeInTheDocument();
+  });
+
+  it('renders title + printer name when open', async () => {
+    render(<HeaterHistoryModal {...defaultProps} />);
+    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(<HeaterHistoryModal {...defaultProps} />);
+    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(<HeaterHistoryModal {...defaultProps} />);
+    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(<HeaterHistoryModal {...defaultProps} initialKind="chamber" />);
+    await waitFor(() => {
+      expect(screen.getByText(/No data recorded yet/i)).toBeInTheDocument();
+    });
+  });
+
+  it('close button triggers onClose', async () => {
+    const onClose = vi.fn();
+    render(<HeaterHistoryModal {...defaultProps} onClose={onClose} />);
+    await waitFor(() => {
+      expect(screen.getByText('Test Printer')).toBeInTheDocument();
+    });
+
+    fireEvent.click(screen.getByLabelText(/Close/i));
+    expect(onClose).toHaveBeenCalled();
+  });
+});

+ 28 - 0
frontend/src/api/client.ts

@@ -5724,6 +5724,13 @@ export const api = {
   getAMSHistory: (printerId: number, amsId: number, hours = 24) =>
     request<AMSHistoryResponse>(`/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<PrinterSensorHistoryResponse>(`/printer-sensor-history/${printerId}?${params.toString()}`);
+  },
+
   // System Info
   getSystemInfo: () => request<SystemInfo>('/system/info'),
   getSystemHealth: () => request<SystemHealthResult>('/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: {

+ 343 - 0
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<HeaterSensorKind, string> = {
+  nozzle: '#fb923c',
+  nozzle_2: '#f97316',
+  bed: '#60a5fa',
+  chamber: '#34d399',
+};
+
+const KIND_TARGET_COLORS: Record<HeaterSensorKind, string> = {
+  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<TimeRange>('24h');
+  const [kind, setKind] = useState<HeaterSensorKind>(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<PrinterSensorHistoryResponse>({
+    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 <TrendingUp className="w-4 h-4 text-red-400" />;
+    if (trend === 'down') return <TrendingDown className="w-4 h-4 text-green-400" />;
+    return <Minus className="w-4 h-4 text-gray-400 dark:text-bambu-gray" />;
+  };
+
+  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 <Flame className="w-4 h-4" />;
+    if (k === 'bed') return <Square className="w-4 h-4" />;
+    return <Box className="w-4 h-4" />;
+  };
+
+  return (
+    <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={onClose}>
+      <div
+        className="rounded-xl w-full max-w-4xl max-h-[90vh] overflow-hidden shadow-xl"
+        style={{ backgroundColor: modalBg }}
+        onClick={e => e.stopPropagation()}
+      >
+        <div className="flex items-center justify-between px-6 py-4 border-b" style={{ borderColor }}>
+          <div>
+            <h2 className="text-lg font-semibold" style={{ color: textPrimary }}>
+              {t('printers.heaterHistory.title', 'Heater History')}
+            </h2>
+            <p className="text-sm" style={{ color: textSecondary }}>{printerName}</p>
+          </div>
+          <button
+            onClick={onClose}
+            className="p-2 rounded-lg transition-colors"
+            style={{ color: textSecondary }}
+            aria-label={t('common.close', 'Close')}
+          >
+            <X className="w-5 h-5" />
+          </button>
+        </div>
+
+        <div className="p-6 space-y-6 overflow-y-auto max-h-[calc(90vh-80px)]">
+          <div className="flex items-center justify-between max-[550px]:flex-col max-[550px]:items-start max-[550px]:gap-3">
+            <div className="inline-flex gap-1 rounded-lg p-1 max-w-full flex-wrap w-fit" style={{ backgroundColor: cardBg }}>
+              {availableKinds.map(k => (
+                <button
+                  key={k}
+                  onClick={() => setKind(k)}
+                  className={`flex items-center gap-2 px-3 py-1.5 text-sm rounded-md transition-colors ${
+                    kind === k ? 'text-white' : ''
+                  }`}
+                  style={kind === k ? { backgroundColor: KIND_COLORS[k] } : { color: textSecondary }}
+                >
+                  <KindIcon k={k} />
+                  {kindLabel(k)}
+                </button>
+              ))}
+            </div>
+
+            <div className="inline-flex gap-1 rounded-lg p-1 max-w-full flex-wrap w-fit" style={{ backgroundColor: cardBg }}>
+              {TIME_RANGES.map(range => (
+                <button
+                  key={range.value}
+                  onClick={() => setTimeRange(range.value)}
+                  className={`px-3 py-1 text-sm rounded-md transition-colors ${
+                    timeRange === range.value ? 'bg-bambu-green text-white' : ''
+                  }`}
+                  style={timeRange !== range.value ? { color: textSecondary } : undefined}
+                >
+                  {range.label}
+                </button>
+              ))}
+            </div>
+          </div>
+
+          <div className="grid grid-cols-4 gap-4 max-[550px]:grid-cols-2">
+            <div className="rounded-lg p-4" style={{ backgroundColor: cardBg }}>
+              <p className="text-xs" style={{ color: textSecondary }}>{t('common.current', 'Current')}</p>
+              <div className="flex items-center gap-2">
+                <p className="text-2xl font-bold" style={{ color: KIND_COLORS[kind] }}>
+                  {currentValue != null ? `${Math.round(currentValue)}°C` : '—'}
+                </p>
+                <TrendIcon trend={trend} />
+              </div>
+              {currentTarget != null && currentTarget > 0 && (
+                <p className="text-xs mt-1" style={{ color: textSecondary }}>
+                  {t('common.target', 'Target')}: {Math.round(currentTarget)}°C
+                </p>
+              )}
+            </div>
+            <div className="rounded-lg p-4" style={{ backgroundColor: cardBg }}>
+              <p className="text-xs" style={{ color: textSecondary }}>{t('common.average', 'Average')}</p>
+              <p className="text-2xl font-bold" style={{ color: textPrimary }}>
+                {series?.avg_value != null ? `${series.avg_value}°C` : '—'}
+              </p>
+            </div>
+            <div className="rounded-lg p-4" style={{ backgroundColor: cardBg }}>
+              <p className="text-xs" style={{ color: textSecondary }}>{t('common.min', 'Min')}</p>
+              <p className="text-2xl font-bold" style={{ color: textPrimary }}>
+                {series?.min_value != null ? `${Math.round(series.min_value)}°C` : '—'}
+              </p>
+            </div>
+            <div className="rounded-lg p-4" style={{ backgroundColor: cardBg }}>
+              <p className="text-xs" style={{ color: textSecondary }}>{t('common.max', 'Max')}</p>
+              <p className="text-2xl font-bold" style={{ color: textPrimary }}>
+                {series?.max_value != null ? `${Math.round(series.max_value)}°C` : '—'}
+              </p>
+            </div>
+          </div>
+
+          <div className="rounded-lg p-4" style={{ backgroundColor: cardBg }}>
+            {isLoading ? (
+              <div className="h-64 flex items-center justify-center" style={{ color: textSecondary }}>
+                {t('common.loading', 'Loading...')}
+              </div>
+            ) : error ? (
+              <div className="h-64 flex items-center justify-center text-red-400">
+                {t('printers.heaterHistory.error', 'Failed to load history')}
+              </div>
+            ) : chartData.length === 0 ? (
+              <div className="h-64 flex items-center justify-center" style={{ color: textSecondary }}>
+                {t('printers.heaterHistory.empty', 'No data recorded yet')}
+              </div>
+            ) : (
+              <ResponsiveContainer width="100%" height={300}>
+                <LineChart data={chartData} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
+                  <CartesianGrid strokeDasharray="3 3" stroke={borderColor} />
+                  <XAxis
+                    dataKey="time"
+                    type="number"
+                    domain={[domainStart, domainEnd]}
+                    tickFormatter={ts =>
+                      new Date(ts).toLocaleTimeString(
+                        [],
+                        applyTimeFormat({ hour: '2-digit', minute: '2-digit' }, timeFormat),
+                      )
+                    }
+                    stroke={axisColor}
+                    fontSize={11}
+                  />
+                  <YAxis
+                    stroke={axisColor}
+                    fontSize={11}
+                    domain={[0, 'auto']}
+                    tickFormatter={v => `${Math.round(v)}°`}
+                  />
+                  <Tooltip
+                    contentStyle={{
+                      backgroundColor: modalBg,
+                      border: `1px solid ${borderColor}`,
+                      borderRadius: 6,
+                      color: textPrimary,
+                    }}
+                    labelFormatter={(ts) =>
+                      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` : '—')}
+                  />
+                  <Legend />
+                  <Line
+                    type="monotone"
+                    dataKey="value"
+                    name={t('common.current', 'Current')}
+                    stroke={KIND_COLORS[kind]}
+                    strokeWidth={2}
+                    dot={false}
+                    isAnimationActive={false}
+                  />
+                  <Line
+                    type="stepAfter"
+                    dataKey="target"
+                    name={t('common.target', 'Target')}
+                    stroke={KIND_TARGET_COLORS[kind]}
+                    strokeDasharray="4 4"
+                    strokeWidth={1.5}
+                    dot={false}
+                    isAnimationActive={false}
+                  />
+                </LineChart>
+              </ResponsiveContainer>
+            )}
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}

+ 10 - 0
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?',

+ 10 - 0
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}}"?',

+ 10 - 0
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}}"?',

+ 10 - 0
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}}" ?',

+ 10 - 0
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}}"?',

+ 10 - 0
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}}」を削除しますか?',

+ 10 - 0
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}}"을(를) 삭제하시겠습니까?',

+ 10 - 0
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}}"?',

+ 10 - 0
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?',

+ 10 - 0
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}}"吗?',

+ 10 - 0
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}}"嗎?',

+ 60 - 1
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')}
                     >
+                      <button
+                        type="button"
+                        className="absolute top-0.5 right-0.5 p-0.5 rounded text-bambu-gray hover:text-white hover:bg-white/10 transition-colors"
+                        title={t('printers.heaterHistory.openLabel', 'View heater history')}
+                        onClick={(e) => {
+                          e.stopPropagation();
+                          setHeaterHistoryModal({ initialKind: 'nozzle', availableKinds: availableHeaterKinds });
+                        }}
+                      >
+                        <LineChartIcon className="w-2.5 h-2.5" />
+                      </button>
                       <HeaterThermometer className="w-3.5 h-3.5 mb-0.5" color="text-orange-400" isHeating={nozzleHeating} />
                       {status.temperatures.nozzle_2 !== undefined ? (
                         <>
@@ -3670,6 +3695,17 @@ function PrinterCard({
                       title={statusControlTitle}
                       onClick={() => canUseStatusControls && setStatusControlMenu(statusControlMenu === 'bed-temp' ? null : 'bed-temp')}
                     >
+                      <button
+                        type="button"
+                        className="absolute top-0.5 right-0.5 p-0.5 rounded text-bambu-gray hover:text-white hover:bg-white/10 transition-colors"
+                        title={t('printers.heaterHistory.openLabel', 'View heater history')}
+                        onClick={(e) => {
+                          e.stopPropagation();
+                          setHeaterHistoryModal({ initialKind: 'bed', availableKinds: availableHeaterKinds });
+                        }}
+                      >
+                        <LineChartIcon className="w-2.5 h-2.5" />
+                      </button>
                       <HeaterThermometer className="w-3.5 h-3.5 mb-0.5" color="text-blue-400" isHeating={bedHeating} />
                       <p className="text-[9px] text-bambu-gray">{t('printers.temperatures.bed')}</p>
                       <p className="text-[11px] text-white">
@@ -3696,12 +3732,23 @@ function PrinterCard({
                         <div
                           className={hasChamberHeater
                             ? statusControlClass
-                            : 'text-center px-2 py-1.5 bg-bambu-dark rounded-lg flex-1 flex flex-col justify-center items-center'}
+                            : 'relative text-center px-2 py-1.5 bg-bambu-dark rounded-lg flex-1 flex flex-col justify-center items-center'}
                           title={hasChamberHeater ? statusControlTitle : undefined}
                           onClick={hasChamberHeater
                             ? () => canUseStatusControls && setStatusControlMenu(statusControlMenu === 'chamber-temp' ? null : 'chamber-temp')
                             : undefined}
                         >
+                          <button
+                            type="button"
+                            className="absolute top-0.5 right-0.5 p-0.5 rounded text-bambu-gray hover:text-white hover:bg-white/10 transition-colors"
+                            title={t('printers.heaterHistory.openLabel', 'View heater history')}
+                            onClick={(e) => {
+                              e.stopPropagation();
+                              setHeaterHistoryModal({ initialKind: 'chamber', availableKinds: availableHeaterKinds });
+                            }}
+                          >
+                            <LineChartIcon className="w-2.5 h-2.5" />
+                          </button>
                           <HeaterThermometer className="w-3.5 h-3.5 mb-0.5" color="text-green-400" isHeating={chamberHeating} />
                           <p className="text-[9px] text-bambu-gray">{t('printers.temperatures.chamber')}</p>
                           <p className="text-[11px] text-white">
@@ -5940,6 +5987,18 @@ function PrinterCard({
         />
       )}
 
+      {/* Heater History Modal (nozzle / bed / chamber) */}
+      {heaterHistoryModal && (
+        <HeaterHistoryModal
+          isOpen={!!heaterHistoryModal}
+          onClose={() => setHeaterHistoryModal(null)}
+          printerId={printer.id}
+          printerName={printer.name}
+          initialKind={heaterHistoryModal.initialKind}
+          availableKinds={heaterHistoryModal.availableKinds}
+        />
+      )}
+
       {/* Link Spool Modal */}
       {linkSpoolModal && (
         <LinkSpoolModal

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/index-DUIDqBr8.js


+ 1 - 1
static/index.html

@@ -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-B3zC_9bK.js"></script>
+    <script type="module" crossorigin src="/assets/index-DUIDqBr8.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-CEe69URA.css">
   </head>
   <body>

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio