Просмотр исходного кода

Read a NULL notification flag as off instead of dropping every provider (issue #2827)

    Adding on_stock_reorder_alert and on_stock_break_alert to the provider
    schema made them required on the way out as well as in: the response model
    inherits the write model. Every on_* column on notification_providers is
    nullable with no server default, and where the table was created from
    Base.metadata before run_migrations, the ALTER ... DEFAULT false that
    introduced those columns was swallowed as a duplicate and never backfilled
    existing rows. Those NULLs were harmless until the flags were read, at
    which point the row failed validation -- and a list is validated as a
    whole, so one row took every provider with it. The route returned 500 and
    the UI rendered an empty list, so configured providers looked deleted.

    Backfill them to off, which is what the sender already assumed: it selects
    providers with IS TRUE, so a NULL flag never sent anything. A NULL flag now
    also reads as off rather than failing the response, across all of them, so
    the next flag added to this schema cannot repeat it. Writes are unchanged.
maziggy 1 неделя назад
Родитель
Сommit
8f182dc181
75 измененных файлов с 8111 добавлено и 205 удалено
  1. 0 7
      CHANGELOG.md
  2. 5 2
      backend/app/api/routes/inventory.py
  3. 324 0
      backend/app/api/routes/location_ha_sensors.py
  4. 16 0
      backend/app/api/routes/notifications.py
  5. 1 0
      backend/app/api/routes/settings.py
  6. 95 0
      backend/app/core/database.py
  7. 5 0
      backend/app/main.py
  8. 1 0
      backend/app/models/__init__.py
  9. 2 0
      backend/app/models/location.py
  10. 76 0
      backend/app/models/location_ha_sensor.py
  11. 7 0
      backend/app/models/notification.py
  12. 7 1
      backend/app/models/notification_template.py
  13. 8 1
      backend/app/models/printer_ha_sensor.py
  14. 140 0
      backend/app/schemas/location_ha_sensor.py
  15. 65 3
      backend/app/schemas/notification.py
  16. 8 0
      backend/app/schemas/notification_template.py
  17. 15 2
      backend/app/schemas/printer_ha_sensor.py
  18. 27 0
      backend/app/schemas/settings.py
  19. 47 7
      backend/app/services/ha_sensor_manager.py
  20. 216 0
      backend/app/services/location_ha_sensor_manager.py
  21. 38 0
      backend/app/services/notification_service.py
  22. 20 0
      backend/app/utils/natural_sort.py
  23. 26 0
      backend/tests/conftest.py
  24. 47 0
      backend/tests/integration/test_ha_sensors_api_1148.py
  25. 570 0
      backend/tests/integration/test_location_ha_sensors_api.py
  26. 15 0
      backend/tests/integration/test_locations_api.py
  27. 112 0
      backend/tests/integration/test_notifications_api.py
  28. 99 0
      backend/tests/unit/services/test_location_sensor_alert_provider_scope_2824.py
  29. 107 0
      backend/tests/unit/test_ha_sensor_alert_template_rename_migration_2824.py
  30. 62 0
      backend/tests/unit/test_ha_sensor_manager_1148.py
  31. 183 0
      backend/tests/unit/test_location_ha_sensor_manager_2824.py
  32. 108 0
      backend/tests/unit/test_location_ha_sensor_unique_binding_migration_2824.py
  33. 42 0
      backend/tests/unit/test_natural_sort.py
  34. 26 0
      backend/tests/unit/test_notification_template_sample_data.py
  35. 52 0
      frontend/src/__tests__/components/AddNotificationModal.test.tsx
  36. 731 0
      frontend/src/__tests__/components/LocationHASensorModal.test.tsx
  37. 443 0
      frontend/src/__tests__/components/LocationSensorOptionsModal.test.tsx
  38. 60 0
      frontend/src/__tests__/components/LocationsModal.test.tsx
  39. 25 0
      frontend/src/__tests__/components/PrinterHASensorRow.test.tsx
  40. 215 0
      frontend/src/__tests__/pages/InventoryPageLocationSensorRequests.test.tsx
  41. 165 0
      frontend/src/__tests__/pages/LocationSensorReadingsCrossPageCache.test.tsx
  42. 225 0
      frontend/src/__tests__/pages/SettingsPage.test.tsx
  43. 61 0
      frontend/src/__tests__/utils/haSensorDisplay.test.ts
  44. 109 0
      frontend/src/__tests__/utils/locationSensorAlertDefaults.test.ts
  45. 68 0
      frontend/src/__tests__/utils/locationSensorColorPrefsLive.test.ts
  46. 82 0
      frontend/src/api/client.ts
  47. 12 0
      frontend/src/components/AddNotificationModal.tsx
  48. 2 20
      frontend/src/components/HASensorModal.tsx
  49. 670 0
      frontend/src/components/LocationHASensorModal.tsx
  50. 452 0
      frontend/src/components/LocationSensorOptionsModal.tsx
  51. 103 33
      frontend/src/components/LocationsModal.tsx
  52. 14 0
      frontend/src/components/NotificationProviderCard.tsx
  53. 4 75
      frontend/src/components/PrinterHASensorRow.tsx
  54. 79 2
      frontend/src/i18n/locales/de.ts
  55. 80 2
      frontend/src/i18n/locales/en.ts
  56. 79 2
      frontend/src/i18n/locales/es.ts
  57. 79 2
      frontend/src/i18n/locales/fr.ts
  58. 79 2
      frontend/src/i18n/locales/it.ts
  59. 79 2
      frontend/src/i18n/locales/ja.ts
  60. 79 2
      frontend/src/i18n/locales/ko.ts
  61. 79 2
      frontend/src/i18n/locales/pt-BR.ts
  62. 79 2
      frontend/src/i18n/locales/ru.ts
  63. 79 2
      frontend/src/i18n/locales/tr.ts
  64. 79 2
      frontend/src/i18n/locales/uk.ts
  65. 78 2
      frontend/src/i18n/locales/zh-CN.ts
  66. 78 2
      frontend/src/i18n/locales/zh-TW.ts
  67. 1 0
      frontend/src/lib/settingsSearch.ts
  68. 337 15
      frontend/src/pages/InventoryPage.tsx
  69. 331 10
      frontend/src/pages/SettingsPage.tsx
  70. 121 0
      frontend/src/utils/haSensorDisplay.ts
  71. 279 0
      frontend/src/utils/locationSensorDefaults.ts
  72. 0 0
      static/assets/index-Bo6-nt-q.js
  73. 1 0
      static/assets/index-C7cOM7tZ.css
  74. 0 1
      static/assets/index-DjndScv6.css
  75. 2 2
      static/index.html

Разница между файлами не показана из-за своего большого размера
+ 0 - 7
CHANGELOG.md


+ 5 - 2
backend/app/api/routes/inventory.py

@@ -67,6 +67,7 @@ from backend.app.utils.filament_ids import (
     normalize_slicer_filament,
 )
 from backend.app.utils.filament_types import is_material_name, nozzle_temp_range, printer_filament_type
+from backend.app.utils.natural_sort import natural_sort_key
 from backend.app.utils.tag_normalization import normalize_tag_uid, normalize_tray_uuid
 
 logger = logging.getLogger(__name__)
@@ -626,8 +627,10 @@ async def list_locations(
 ):
     """List all storage locations with spool counts."""
     settings = await _load_settings_map(db)
-    result = await db.execute(select(Location).order_by(Location.name))
-    locations = list(result.scalars().all())
+    result = await db.execute(select(Location))
+    # Sorted in Python, not SQL: "Drybox 2" belongs before "Drybox 10", and
+    # ORDER BY name gives the opposite (plain lexicographic) order.
+    locations = sorted(result.scalars().all(), key=lambda loc: natural_sort_key(loc.name))
     counts = await _spool_counts_for_locations(db, locations, settings)
     return [_location_to_response(loc, counts.get(loc.id, 0)) for loc in locations]
 

+ 324 - 0
backend/app/api/routes/location_ha_sensors.py

@@ -0,0 +1,324 @@
+"""API routes for Home Assistant sensors bound to a storage location (#2824)."""
+
+import logging
+
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy import select
+from sqlalchemy.exc import IntegrityError
+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.location import Location
+from backend.app.models.location_ha_sensor import LocationHASensor
+from backend.app.models.user import User
+from backend.app.schemas.location_ha_sensor import (
+    HADisplayEntity,
+    LocationHASensorCreate,
+    LocationHASensorReading,
+    LocationHASensorResponse,
+    LocationHASensorUpdate,
+)
+from backend.app.services.homeassistant import homeassistant_service
+from backend.app.services.location_ha_sensor_manager import location_ha_sensor_manager
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/location-ha-sensors", tags=["location-ha-sensors"])
+
+# Reuse the smart-plug permissions, same as ha_sensors.py: both surfaces are
+# "the Home Assistant integration", just scoped to a location instead of a
+# printer. INVENTORY_* would put HA entity bindings behind
+# can_manage_inventory, which defaults to on for API keys (see auth.py) —
+# an inventory-scoped key (e.g. a SpoolBuddy kiosk) would then be able to
+# create, edit and delete HA sensor bindings, a capability the printer
+# sibling deliberately keeps admin-only by leaving SMART_PLUGS_CREATE/
+# UPDATE/DELETE off the API-key allowlist entirely.
+_READ = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ)
+_CREATE = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CREATE)
+_UPDATE = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_UPDATE)
+_DELETE = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_DELETE)
+
+
+# Mirrors categoryFor() in LocationHASensorModal.tsx, which also gates that
+# dialog's entity picker. A device class outside these three has no category
+# and is not subject to the one-per-location rule below.
+#
+# "moisture" is deliberately not mapped to humidity: it is Home Assistant's
+# binary wet/dry class, so a leak detector would otherwise block a real
+# hygrometer on the same location, and it could not carry the category's
+# thresholds anyway — the schema rejects alert_above/alert_below for
+# kind="binary".
+_CATEGORY_BY_DEVICE_CLASS = {
+    "temperature": "temperature",
+    "humidity": "humidity",
+    "battery": "battery",
+}
+
+
+def _category_for(device_class: str | None) -> str | None:
+    return _CATEGORY_BY_DEVICE_CLASS.get(device_class) if device_class else None
+
+
+async def _reject_duplicate_category(
+    db: AsyncSession,
+    location_id: int,
+    device_class: str | None,
+    exclude_sensor_id: int | None = None,
+) -> None:
+    """One sensor per category per location, enforced here and not only in the UI.
+
+    The inventory column and the card footer both pick their reading with a
+    single ``find`` over the location's sensors, so a second temperature
+    sensor does not show up alongside the first — it silently shadows it
+    depending on row order. The modal already prompts to replace rather than
+    add, so this closes the same rule for direct API callers instead of
+    leaving the guarantee resting on the client.
+    """
+    category = _category_for(device_class)
+    if category is None:
+        return
+
+    query = select(LocationHASensor).where(LocationHASensor.location_id == location_id)
+    if exclude_sensor_id is not None:
+        query = query.where(LocationHASensor.id != exclude_sensor_id)
+
+    result = await db.execute(query)
+    for other in result.scalars().all():
+        if _category_for(other.device_class) == category:
+            raise HTTPException(
+                400,
+                f"This location already has a {category} sensor ({other.entity_id}). "
+                "Edit that sensor to point at a different entity instead.",
+            )
+
+
+async def _refresh_quietly(sensor: LocationHASensor, db: AsyncSession) -> None:
+    """Take a first reading without letting it fail the write that preceded it.
+
+    The sensor row is committed before this runs. A failure here costs the
+    card one poll interval of blank state, which is not worth turning a
+    successful save into an error response.
+    """
+    try:
+        await location_ha_sensor_manager.refresh_one(db, sensor)
+    except Exception as e:
+        logger.warning("Could not read %s right after saving it: %s", sensor.entity_id, e)
+
+
+@router.get("/", response_model=list[LocationHASensorResponse])
+async def list_location_ha_sensors(
+    location_id: int | None = None,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = _READ,
+):
+    """List configured sensors, grouped by location and in display order."""
+    query = select(LocationHASensor)
+    if location_id is not None:
+        query = query.where(LocationHASensor.location_id == location_id)
+    result = await db.execute(query.order_by(LocationHASensor.location_id, LocationHASensor.sort_order))
+    return list(result.scalars().all())
+
+
+# Must precede /{sensor_id} so "entities" is not parsed as an id.
+@router.get("/entities", response_model=list[HADisplayEntity])
+async def list_bindable_entities(
+    search: str | None = None,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = _READ,
+):
+    """List the Home Assistant entities that can be bound to a storage location."""
+    from backend.app.api.routes.settings import get_homeassistant_settings
+
+    ha_settings = await get_homeassistant_settings(db)
+    if not ha_settings["ha_url"] or not ha_settings["ha_token"]:
+        raise HTTPException(
+            400,
+            "Home Assistant not configured. Please set HA URL and token in Settings → Network → Home Assistant.",
+        )
+
+    entities = await homeassistant_service.list_display_entities(ha_settings["ha_url"], ha_settings["ha_token"], search)
+    return [HADisplayEntity(**e) for e in entities]
+
+
+@router.get("/by-location/{location_id}/readings", response_model=list[LocationHASensorReading])
+async def get_location_sensor_readings(
+    location_id: int,
+    show_on_card: bool = True,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = _READ,
+):
+    """Live state of a location's card-visible sensors.
+
+    Served from the poller's cache, so a page full of filament cards costs
+    Home Assistant nothing. A sensor the poller has not reached yet falls
+    back to its last persisted state, marked unreachable, rather than
+    vanishing from the card on every restart.
+    """
+    conditions = [LocationHASensor.location_id == location_id]
+    if show_on_card:
+        conditions.append(LocationHASensor.show_on_card.is_(True))
+
+    result = await db.execute(
+        select(LocationHASensor).where(*conditions).order_by(LocationHASensor.sort_order, LocationHASensor.id)
+    )
+
+    readings = []
+    for sensor in result.scalars().all():
+        cached = location_ha_sensor_manager.get_reading(sensor.id)
+        readings.append(
+            LocationHASensorReading(
+                id=sensor.id,
+                name=sensor.name,
+                entity_id=sensor.entity_id,
+                kind=sensor.kind,
+                device_class=sensor.device_class,
+                unit=sensor.unit,
+                state=cached.state if cached else sensor.last_state,
+                value=cached.value if cached else None,
+                alerting=cached.alerting if cached else False,
+                reachable=cached.reachable if cached else False,
+                alert_state=sensor.alert_state,
+                alert_above=sensor.alert_above,
+                alert_below=sensor.alert_below,
+                last_changed=sensor.last_changed,
+                show_on_card=sensor.show_on_card,
+            )
+        )
+    return readings
+
+
+@router.post("/", response_model=LocationHASensorResponse)
+async def create_location_ha_sensor(
+    data: LocationHASensorCreate,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = _CREATE,
+):
+    """Bind a Home Assistant entity to a storage location."""
+    location = await db.get(Location, data.location_id)
+    if not location:
+        raise HTTPException(404, "Location not found")
+
+    existing = await db.execute(
+        select(LocationHASensor).where(
+            LocationHASensor.location_id == data.location_id,
+            LocationHASensor.entity_id == data.entity_id,
+        )
+    )
+    if existing.scalar_one_or_none():
+        raise HTTPException(400, f"{data.entity_id} is already bound to this location")
+
+    await _reject_duplicate_category(db, data.location_id, data.device_class)
+
+    sensor = LocationHASensor(**data.model_dump())
+    db.add(sensor)
+    try:
+        await db.commit()
+    except IntegrityError:
+        # The duplicate check above is read-then-insert, so a concurrent
+        # create for the same (location, entity) can get past it — the unique
+        # index is the backstop, and its loser should read like the pre-check.
+        await db.rollback()
+        raise HTTPException(400, f"{data.entity_id} is already bound to this location") from None
+    await db.refresh(sensor)
+    logger.info("Bound HA entity %s to location %s as '%s'", sensor.entity_id, sensor.location_id, sensor.name)
+
+    # Read it once now so the card shows a state immediately instead of after
+    # the next poll tick. Best-effort: the row is already committed, so
+    # letting a Home Assistant hiccup 500 the request would report a failure
+    # for work that succeeded — and the retry would come back "already bound".
+    await _refresh_quietly(sensor, db)
+    return sensor
+
+
+@router.get("/{sensor_id}", response_model=LocationHASensorResponse)
+async def get_location_ha_sensor(
+    sensor_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = _READ,
+):
+    sensor = await db.get(LocationHASensor, sensor_id)
+    if not sensor:
+        raise HTTPException(404, "Sensor not found")
+    return sensor
+
+
+@router.patch("/{sensor_id}", response_model=LocationHASensorResponse)
+async def update_location_ha_sensor(
+    sensor_id: int,
+    data: LocationHASensorUpdate,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = _UPDATE,
+):
+    sensor = await db.get(LocationHASensor, sensor_id)
+    if not sensor:
+        raise HTTPException(404, "Sensor not found")
+
+    updates = data.model_dump(exclude_unset=True)
+
+    # Re-run the create-time rules against the merged row. A PATCH that only
+    # sets show_on_card has no entity_id or alert_state in its payload, so the
+    # schema alone cannot tell whether the result is coherent.
+    merged = {field: getattr(sensor, field) for field in LocationHASensorCreate.model_fields}
+    merged.update(updates)
+    try:
+        LocationHASensorCreate(**merged)
+    except ValueError as e:
+        raise HTTPException(422, str(e)) from e
+
+    # Same uniqueness rule as create: repointing a sensor at an entity the
+    # location already has would leave two rows fighting over one reading.
+    new_entity = updates.get("entity_id")
+    if new_entity and new_entity != sensor.entity_id:
+        clash = await db.execute(
+            select(LocationHASensor).where(
+                LocationHASensor.location_id == sensor.location_id,
+                LocationHASensor.entity_id == new_entity,
+                LocationHASensor.id != sensor.id,
+            )
+        )
+        if clash.scalar_one_or_none():
+            raise HTTPException(400, f"{new_entity} is already bound to this location")
+
+    # Same one-per-category rule as create, against the merged row and
+    # excluding this sensor — repointing a sensor within its own category
+    # (the modal's replace flow) stays allowed.
+    if "device_class" in updates:
+        await _reject_duplicate_category(db, sensor.location_id, merged["device_class"], exclude_sensor_id=sensor.id)
+
+    for field, value in updates.items():
+        setattr(sensor, field, value)
+    # Read before commit: after a rollback the instance is expired, and
+    # touching its attributes from async code raises MissingGreenlet.
+    entity_id = sensor.entity_id
+    try:
+        await db.commit()
+    except IntegrityError:
+        # Same backstop as create: the clash check above races a concurrent
+        # write, and the unique index decides who loses.
+        await db.rollback()
+        raise HTTPException(400, f"{entity_id} is already bound to this location") from None
+    await db.refresh(sensor)
+
+    # The entity or its alert rule may have changed under the cached reading.
+    await _refresh_quietly(sensor, db)
+    return sensor
+
+
+@router.delete("/{sensor_id}")
+async def delete_location_ha_sensor(
+    sensor_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = _DELETE,
+):
+    sensor = await db.get(LocationHASensor, sensor_id)
+    if not sensor:
+        raise HTTPException(404, "Sensor not found")
+
+    name = sensor.name
+    await db.delete(sensor)
+    await db.commit()
+    location_ha_sensor_manager.forget(sensor_id)
+    logger.info("Removed location HA sensor '%s'", name)
+    return {"message": f"Sensor '{name}' removed"}

+ 16 - 0
backend/app/api/routes/notifications.py

@@ -51,6 +51,11 @@ def _provider_to_dict(provider: NotificationProvider) -> dict:
         "on_ai_failure_detection": provider.on_ai_failure_detection,
         "on_filament_low": provider.on_filament_low,
         "on_maintenance_due": provider.on_maintenance_due,
+        # Home Assistant sensor alerts (#1148, #2824). Both directions of this
+        # file are hand-maintained field maps, so a column missing here reads
+        # back as the schema default (False) no matter what the row holds.
+        "on_ha_sensor_alert": provider.on_ha_sensor_alert,
+        "on_location_ha_sensor_alert": provider.on_location_ha_sensor_alert,
         # AMS environmental alarms (regular AMS)
         "on_ams_humidity_high": provider.on_ams_humidity_high,
         "on_ams_temperature_high": provider.on_ams_temperature_high,
@@ -65,6 +70,11 @@ def _provider_to_dict(provider: NotificationProvider) -> dict:
         "on_bed_cooled": provider.on_bed_cooled,
         # First layer complete
         "on_first_layer_complete": provider.on_first_layer_complete,
+        # Inventory stock alerts. Absent here, the toggles above always read
+        # back off no matter what the row holds — the same hand-maintained
+        # field map the Home Assistant comment warns about.
+        "on_stock_reorder_alert": provider.on_stock_reorder_alert,
+        "on_stock_break_alert": provider.on_stock_break_alert,
         # Print queue events
         "on_queue_job_added": provider.on_queue_job_added,
         "on_queue_job_assigned": provider.on_queue_job_assigned,
@@ -135,6 +145,9 @@ async def create_notification_provider(
         on_ai_failure_detection=provider_data.on_ai_failure_detection,
         on_filament_low=provider_data.on_filament_low,
         on_maintenance_due=provider_data.on_maintenance_due,
+        # Home Assistant sensor alerts (#1148, #2824)
+        on_ha_sensor_alert=provider_data.on_ha_sensor_alert,
+        on_location_ha_sensor_alert=provider_data.on_location_ha_sensor_alert,
         # AMS environmental alarms (regular AMS)
         on_ams_humidity_high=provider_data.on_ams_humidity_high,
         on_ams_temperature_high=provider_data.on_ams_temperature_high,
@@ -149,6 +162,9 @@ async def create_notification_provider(
         on_bed_cooled=provider_data.on_bed_cooled,
         # First layer complete
         on_first_layer_complete=provider_data.on_first_layer_complete,
+        # Inventory stock alerts
+        on_stock_reorder_alert=provider_data.on_stock_reorder_alert,
+        on_stock_break_alert=provider_data.on_stock_break_alert,
         # Print queue events
         on_queue_job_added=provider_data.on_queue_job_added,
         on_queue_job_assigned=provider_data.on_queue_job_assigned,

+ 1 - 0
backend/app/api/routes/settings.py

@@ -224,6 +224,7 @@ async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) -
             "stagger_group_size",
             "stagger_interval_minutes",
             "forecast_global_lead_time_days",
+            "location_sensor_poll_interval",
             "finance_budget_reset_day",
             "session_max_hours",
             "pipeline_max_copies",

+ 95 - 0
backend/app/core/database.py

@@ -303,6 +303,7 @@ async def init_db():
         library,
         local_preset,
         location,
+        location_ha_sensor,
         long_lived_token,
         maintenance,
         notification,
@@ -3938,6 +3939,34 @@ async def run_migrations(conn):
             conn, "ALTER TABLE notification_providers ADD COLUMN on_stock_break_alert BOOLEAN DEFAULT false"
         )
 
+    # Backfill the two flags above. The DEFAULT on those ALTERs only reaches
+    # existing rows when the ALTER is the statement that adds the column -- and
+    # on an install whose notification_providers table was (re)created from
+    # Base.metadata, create_all() had already added them by the time migrations
+    # ran, so _safe_execute swallowed the ALTER as a duplicate column and every
+    # pre-existing row kept NULL. Harmless while nothing read the flags; a 500
+    # on the whole provider list once #2827 declared them on the response
+    # schema, because pydantic will not accept None for a bool.
+    #
+    # false matches both the intent of the DEFAULT above and the behaviour the
+    # rows already have: _get_providers_for_event filters on `.is_(True)`, so a
+    # NULL flag never sent anything. Idempotent -- the WHERE matches nothing on
+    # the second run.
+    async with conn.begin_nested():
+        stock_backfill = await conn.execute(
+            text(
+                "UPDATE notification_providers SET on_stock_reorder_alert = :off WHERE on_stock_reorder_alert IS NULL"
+            ),
+            {"off": False},
+        )
+        stock_backfill_break = await conn.execute(
+            text("UPDATE notification_providers SET on_stock_break_alert = :off WHERE on_stock_break_alert IS NULL"),
+            {"off": False},
+        )
+    repaired = (stock_backfill.rowcount or 0) + (stock_backfill_break.rowcount or 0)
+    if repaired:
+        logger.info("Backfilled %s NULL inventory stock alert flag(s) on notification_providers", repaired)
+
     # Migration: Heal orphan auth-related rows left behind by user-delete
     # on SQLite. user_oidc_links, user_totp, user_otp_codes (introduced in
     # PR #933) and long_lived_tokens (PR #1108) all declare ON DELETE
@@ -4468,6 +4497,30 @@ async def run_migrations(conn):
         conn, "ALTER TABLE notification_providers ADD COLUMN on_ams_drying_suspended BOOLEAN DEFAULT TRUE"
     )
 
+    # Migration: storage location sensor alerts (#2824), own column rather than
+    # reusing on_ha_sensor_alert. That column can be scoped to one printer
+    # (printer_id), and a location alert has no printer to scope by — sharing
+    # the column meant a provider narrowed to one printer's sensors silently
+    # also received every drybox alert, with no toggle to separate the two.
+    await _safe_execute(
+        conn, "ALTER TABLE notification_providers ADD COLUMN on_location_ha_sensor_alert BOOLEAN DEFAULT FALSE"
+    )
+
+    # Migration: rename the ha_sensor_alert template (#2824). "Home Assistant
+    # Sensor Alert" was fine as a name while it was the only such template;
+    # next to the new "Storage Location Sensor Alert" it no longer says which
+    # one is the printer's. See _migrate_rename_user_print_template_names for
+    # why this is a plain UPDATE guarded on the old name rather than a
+    # DEFAULT_TEMPLATES re-seed.
+    await _migrate_rename_ha_sensor_alert_template(conn)
+
+    # Migration: back the one-binding-per-(location, entity) rule with a unique
+    # index (#2824). The API's duplicate check is read-then-insert, so two
+    # concurrent creates could both pass it; the index turns the loser into an
+    # IntegrityError the route maps back to the same 400. create_all() adds it
+    # on fresh installs only — this covers databases whose table predates it.
+    await _migrate_location_ha_sensor_unique_binding(conn)
+
     # Migration: repair the tare of spools the RFID auto-add gave the wrong
     # Bambu spool row (#2909). Runs last so the spool catalogue it reads is
     # whatever this database actually holds.
@@ -4478,6 +4531,48 @@ async def run_migrations(conn):
     await _migrate_drop_ams_slot_locations(conn)
 
 
+async def _migrate_rename_ha_sensor_alert_template(conn) -> None:
+    """Rename the ha_sensor_alert template to "Printer Sensor Alert" (#2824).
+
+    Renames only if ``name`` is still the old default — an admin who renamed
+    the template themselves keeps their custom name.
+    """
+    from sqlalchemy import text
+
+    await conn.execute(
+        text("UPDATE notification_templates SET name = :new WHERE event_type = :et AND name = :old"),
+        {"new": "Printer Sensor Alert", "et": "ha_sensor_alert", "old": "Home Assistant Sensor Alert"},
+    )
+
+
+async def _migrate_location_ha_sensor_unique_binding(conn) -> None:
+    """Unique index on location_ha_sensors (location_id, entity_id) (#2824).
+
+    Same name and shape as the Index in the model, so fresh installs (which
+    get it from create_all) and upgraded ones end up identical.
+
+    Rows that already violate it — duplicates slipped in through the pre-index
+    race — are collapsed to the oldest row first, because CREATE UNIQUE INDEX
+    refuses to build over duplicates and _safe_execute would re-raise that,
+    aborting startup. The oldest row wins: it is the one the card and the
+    poller cache were already keyed on.
+    """
+    from sqlalchemy import text
+
+    async with conn.begin_nested():
+        await conn.execute(
+            text(
+                "DELETE FROM location_ha_sensors WHERE id NOT IN ("
+                "SELECT MIN(id) FROM location_ha_sensors GROUP BY location_id, entity_id)"
+            )
+        )
+    await _safe_execute(
+        conn,
+        "CREATE UNIQUE INDEX IF NOT EXISTS uq_location_ha_sensors_location_entity "
+        "ON location_ha_sensors (location_id, entity_id)",
+    )
+
+
 async def _migrate_drop_ams_slot_locations(conn) -> None:
     """Remove imported AMS slot markers from the storage-location catalogue.
 

+ 5 - 0
backend/app/main.py

@@ -43,6 +43,7 @@ from backend.app.api.routes import (
     library_variants,
     local_backup,
     local_presets,
+    location_ha_sensors,
     maintenance,
     makerworld,
     metrics,
@@ -105,6 +106,7 @@ from backend.app.services.ha_sensor_manager import ha_sensor_manager
 from backend.app.services.homeassistant import homeassistant_service
 from backend.app.services.library_trash import library_trash_service
 from backend.app.services.local_backup import local_backup_service
+from backend.app.services.location_ha_sensor_manager import location_ha_sensor_manager
 from backend.app.services.mqtt_relay import mqtt_relay
 from backend.app.services.mqtt_smart_plug import mqtt_smart_plug_service
 from backend.app.services.notification_service import notification_service
@@ -8811,6 +8813,7 @@ async def lifespan(app: FastAPI):
 
     # Start the Home Assistant sensor poller (#1148)
     ha_sensor_manager.start()
+    location_ha_sensor_manager.start()
 
     # Resume any pending auto-offs that were interrupted by restart
     await smart_plug_manager.resume_pending_auto_offs()
@@ -8895,6 +8898,7 @@ async def lifespan(app: FastAPI):
     print_scheduler.stop()
     smart_plug_manager.stop_scheduler()
     ha_sensor_manager.stop()
+    location_ha_sensor_manager.stop()
     notification_service.stop_digest_scheduler()
     github_backup_service.stop_scheduler()
     local_backup_service.stop_scheduler()
@@ -9385,6 +9389,7 @@ app.include_router(orca_cloud.router, prefix=app_settings.api_prefix)
 app.include_router(local_presets.router, prefix=app_settings.api_prefix)
 app.include_router(smart_plugs.router, prefix=app_settings.api_prefix)
 app.include_router(ha_sensors.router, prefix=app_settings.api_prefix)
+app.include_router(location_ha_sensors.router, prefix=app_settings.api_prefix)
 app.include_router(print_log.router, prefix=app_settings.api_prefix)
 app.include_router(print_queue.router, prefix=app_settings.api_prefix)
 app.include_router(scheduled_dryings.router, prefix=app_settings.api_prefix)

+ 1 - 0
backend/app/models/__init__.py

@@ -11,6 +11,7 @@ from backend.app.models.kprofile_note import KProfileNote
 from backend.app.models.library import FileVariantGroup, LibraryFile, LibraryFolder
 from backend.app.models.local_preset import LocalPreset
 from backend.app.models.location import Location
+from backend.app.models.location_ha_sensor import LocationHASensor
 from backend.app.models.long_lived_token import LongLivedToken
 from backend.app.models.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance
 from backend.app.models.notification import NotificationLog

+ 2 - 0
backend/app/models/location.py

@@ -7,6 +7,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
 from backend.app.core.database import Base
 
 if TYPE_CHECKING:
+    from backend.app.models.location_ha_sensor import LocationHASensor
     from backend.app.models.spool import Spool
 
 
@@ -25,3 +26,4 @@ class Location(Base):
     updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
 
     spools: Mapped[list["Spool"]] = relationship(back_populates="location")
+    ha_sensors: Mapped[list["LocationHASensor"]] = relationship(back_populates="location", cascade="all, delete-orphan")

+ 76 - 0
backend/app/models/location_ha_sensor.py

@@ -0,0 +1,76 @@
+from datetime import datetime
+
+from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Index, Integer, String, func
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from backend.app.core.database import Base
+
+# Width of the last_state column. The poller truncates what it persists to
+# this, because a numeric entity can start reporting free text (an enum, an
+# error string) longer than the column — SQLite stores it anyway, but
+# PostgreSQL rejects the row and takes the whole poll batch's commit with it.
+LAST_STATE_MAX_LENGTH = 64
+
+
+class LocationHASensor(Base):
+    """A read-only Home Assistant entity bound to a storage location (#2824).
+
+    Mirrors ``PrinterHASensor`` for dryboxes, bins and shelves instead of
+    printers — same read-only binding, alert rule and notification, but no
+    print-blocking: holding a print queue doesn't mean anything for a
+    storage bin.
+    """
+
+    __tablename__ = "location_ha_sensors"
+    # The API rejects a duplicate (location, entity) binding, but that check is
+    # read-then-insert — two concurrent creates can both pass it. This index is
+    # the backstop that turns the loser into an IntegrityError instead of a
+    # second row silently shadowing the first. create_all() only covers fresh
+    # installs; upgraded databases get it from
+    # _migrate_location_ha_sensor_unique_binding in core/database.py, which
+    # must create the same index under the same name.
+    __table_args__ = (Index("uq_location_ha_sensors_location_entity", "location_id", "entity_id", unique=True),)
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    location_id: Mapped[int] = mapped_column(ForeignKey("locations.id", ondelete="CASCADE"), index=True)
+
+    name: Mapped[str] = mapped_column(String(100))
+    entity_id: Mapped[str] = mapped_column(String(255))
+
+    # "binary" for binary_sensor.*, "numeric" for sensor.*. Decides how the
+    # state is rendered and which alert fields apply.
+    kind: Mapped[str] = mapped_column(String(16), default="binary")
+
+    # HA's own device_class, snapshotted when the entity is bound. Drives the
+    # category a sensor is treated as (temperature/humidity/battery) and the
+    # unit shown next to the value.
+    device_class: Mapped[str | None] = mapped_column(String(32), nullable=True)
+    # Numeric only: "°C", "%", ... shown next to the value.
+    unit: Mapped[str | None] = mapped_column(String(16), nullable=True)
+
+    # What counts as needing attention. One notion, two consumers: the
+    # colorized value on the card/table and the notification. Binary sensors
+    # use alert_state ("on"/"off"/None), numeric ones the thresholds. All
+    # None means "just show the value".
+    alert_state: Mapped[str | None] = mapped_column(String(8), nullable=True)
+    alert_above: Mapped[float | None] = mapped_column(Float, nullable=True)
+    alert_below: Mapped[float | None] = mapped_column(Float, nullable=True)
+
+    notify_on_alert: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
+
+    show_on_card: Mapped[bool] = mapped_column(Boolean, default=True, server_default="1")
+    sort_order: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
+
+    # Last poll result. Persisted so a restart doesn't blank the card until the
+    # first poll lands, and so notifications only fire on a real transition.
+    last_state: Mapped[str | None] = mapped_column(String(LAST_STATE_MAX_LENGTH), nullable=True)
+    last_changed: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    last_checked: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
+
+    location: Mapped["Location"] = relationship(back_populates="ha_sensors")
+
+
+from backend.app.models.location import Location  # noqa: E402

+ 7 - 0
backend/app/models/notification.py

@@ -89,6 +89,13 @@ class NotificationProvider(Base):
     # Event triggers - Home Assistant sensors bound to a printer (#1148)
     on_ha_sensor_alert = Column(Boolean, default=False)  # Bound HA sensor entered its alert state
 
+    # Event triggers - Home Assistant sensors bound to a storage location (#2824)
+    # Its own column rather than reusing on_ha_sensor_alert above: that one can
+    # be scoped to a single printer, and a location alert has no printer to
+    # scope by, so sharing it would leak drybox alerts to a provider narrowed
+    # to one printer's sensors.
+    on_location_ha_sensor_alert = Column(Boolean, default=False)
+
     # Event triggers - Build plate detection
     on_plate_not_empty = Column(Boolean, default=True)  # Objects detected on plate before print
     # Off by default: fires after every print, alongside the print-complete alert (#2525)

+ 7 - 1
backend/app/models/notification_template.py

@@ -139,10 +139,16 @@ DEFAULT_TEMPLATES = [
     },
     {
         "event_type": "ha_sensor_alert",
-        "name": "Home Assistant Sensor Alert",
+        "name": "Printer Sensor Alert",
         "title_template": "Sensor Alert",
         "body_template": "{printer}: {sensor} is {state}",
     },
+    {
+        "event_type": "location_ha_sensor_alert",
+        "name": "Storage Location Sensor Alert",
+        "title_template": "Sensor Alert",
+        "body_template": "{location}: {sensor} is {state}",
+    },
     {
         "event_type": "first_layer_complete",
         "name": "First Layer Complete",

+ 8 - 1
backend/app/models/printer_ha_sensor.py

@@ -5,6 +5,13 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from backend.app.core.database import Base
 
+# Width of the last_state column. The poller truncates what it persists to
+# this, because a numeric entity can start reporting free text (an enum, an
+# error string) longer than the column -- SQLite stores it anyway, but
+# PostgreSQL rejects the row and takes the whole poll batch's commit with it.
+# Its sibling in models/location_ha_sensor.py says the same for that table.
+LAST_STATE_MAX_LENGTH = 64
+
 
 class PrinterHASensor(Base):
     """A read-only Home Assistant entity bound to a printer (#1148, #448).
@@ -59,7 +66,7 @@ class PrinterHASensor(Base):
 
     # Last poll result. Persisted so a restart doesn't blank the card until the
     # first poll lands, and so notifications only fire on a real transition.
-    last_state: Mapped[str | None] = mapped_column(String(64), nullable=True)
+    last_state: Mapped[str | None] = mapped_column(String(LAST_STATE_MAX_LENGTH), nullable=True)
     last_changed: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
     last_checked: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
 

+ 140 - 0
backend/app/schemas/location_ha_sensor.py

@@ -0,0 +1,140 @@
+"""Schemas for Home Assistant entities bound to a storage location (#2824)."""
+
+from datetime import datetime
+from typing import Literal
+
+from pydantic import BaseModel, Field, model_validator
+
+from backend.app.schemas.printer_ha_sensor import HADisplayEntity  # noqa: F401
+
+
+class LocationHASensorBase(BaseModel):
+    location_id: int
+    name: str = Field(..., min_length=1, max_length=100)
+    # max_length matches the column (String(255)). The pattern's [a-z0-9_]+ is
+    # unbounded, so a direct API caller — the picker only ever offers real
+    # Home Assistant ids — could send a longer one: SQLite stores it, but
+    # PostgreSQL raises DataError, and the create/update routes only map
+    # IntegrityError, so it would surface as a 500 instead of a 422.
+    entity_id: str = Field(..., max_length=255, pattern=r"^(binary_sensor|sensor)\.[a-z0-9_]+$")
+    kind: Literal["binary", "numeric"] = "binary"
+    device_class: str | None = Field(default=None, max_length=32)
+    unit: str | None = Field(default=None, max_length=16)
+
+    alert_state: Literal["on", "off"] | None = None
+    # allow_inf_nan=False: pydantic's lax mode coerces the strings "nan"/"inf"
+    # into real NaN/Infinity floats. A NaN threshold satisfies the "notify
+    # needs an alert condition" rule below yet every comparison against it is
+    # False — a notification that can never fire — and it skips the
+    # below-vs-above ordering check the same way. Responses serialize NaN as
+    # null, so the UI would show an empty field over a poisoned row.
+    alert_above: float | None = Field(default=None, allow_inf_nan=False)
+    alert_below: float | None = Field(default=None, allow_inf_nan=False)
+
+    notify_on_alert: bool = False
+    show_on_card: bool = True
+    sort_order: int = Field(default=0, ge=0, le=999)
+
+    @model_validator(mode="after")
+    def validate_kind_matches_entity(self) -> "LocationHASensorBase":
+        domain = self.entity_id.split(".")[0]
+        expected = "binary" if domain == "binary_sensor" else "numeric"
+        if self.kind != expected:
+            raise ValueError(f"kind must be '{expected}' for a {domain} entity")
+
+        # Alert fields are per-kind: a threshold on a battery sensor and an
+        # on/off alert on a temperature reading are both configuration the
+        # poller would silently ignore, so reject them at the edge instead.
+        if self.kind == "binary" and (self.alert_above is not None or self.alert_below is not None):
+            raise ValueError("alert_above/alert_below only apply to numeric sensors")
+        if self.kind == "numeric" and self.alert_state is not None:
+            raise ValueError("alert_state only applies to binary sensors")
+        if self.alert_above is not None and self.alert_below is not None and self.alert_below >= self.alert_above:
+            raise ValueError("alert_below must be lower than alert_above")
+
+        # A notification with nothing to trigger on would never fire — that
+        # reads as a broken feature, not as a no-op.
+        if self.notify_on_alert and not self._has_alert_condition():
+            raise ValueError("notify_on_alert requires an alert condition")
+        return self
+
+    def _has_alert_condition(self) -> bool:
+        return self.alert_state is not None or self.alert_above is not None or self.alert_below is not None
+
+
+class LocationHASensorCreate(LocationHASensorBase):
+    pass
+
+
+class LocationHASensorUpdate(BaseModel):
+    """Partial update. Validated against the merged row in the route, because
+    the per-kind rules above need fields this payload may not carry."""
+
+    name: str | None = Field(default=None, min_length=1, max_length=100)
+    # Same column-width bound as the base schema; PATCH reaches the same row.
+    entity_id: str | None = Field(default=None, max_length=255, pattern=r"^(binary_sensor|sensor)\.[a-z0-9_]+$")
+    kind: Literal["binary", "numeric"] | None = None
+    device_class: str | None = Field(default=None, max_length=32)
+    unit: str | None = Field(default=None, max_length=16)
+    alert_state: Literal["on", "off"] | None = None
+    # Same allow_inf_nan story as the base schema. The route's merged-row
+    # re-validation would catch these too, but rejecting them here keeps the
+    # error attached to the offending field.
+    alert_above: float | None = Field(default=None, allow_inf_nan=False)
+    alert_below: float | None = Field(default=None, allow_inf_nan=False)
+    notify_on_alert: bool | None = None
+    show_on_card: bool | None = None
+    sort_order: int | None = Field(default=None, ge=0, le=999)
+
+
+class LocationHASensorResponse(LocationHASensorBase):
+    # Reads must tolerate what writes now reject, or one legacy row 500s the
+    # whole list. Three constraints are relaxed here on purpose:
+    #
+    # * the NaN/inf thresholds a row could carry before allow_inf_nan landed —
+    #   serialization turns them into null, which is also what the edit form
+    #   should show;
+    # * the entity_id length bound, for a row created before max_length existed
+    #   (SQLite never enforced the column's 255, so those rows are real);
+    # * the entity_id pattern, which the same generation of rows predates.
+    #
+    # Every one of them is still rejected on the way in, so this widens what
+    # can be read back, never what can be stored.
+    alert_above: float | None = None
+    alert_below: float | None = None
+    entity_id: str
+
+    id: int
+    last_state: str | None = None
+    last_changed: datetime | None = None
+    last_checked: datetime | None = None
+    created_at: datetime
+    updated_at: datetime
+
+    class Config:
+        from_attributes = True
+
+
+class LocationHASensorReading(BaseModel):
+    """One sensor's live state, as the filament card and inventory table render it."""
+
+    id: int
+    name: str
+    entity_id: str
+    kind: str
+    device_class: str | None = None
+    unit: str | None = None
+    # Raw HA state: "on"/"off" for binary, the numeric string for sensors.
+    # None when the entity is unavailable or has not been polled yet.
+    state: str | None = None
+    value: float | None = None  # numeric sensors only, parsed from state
+    alerting: bool = False
+    reachable: bool = True
+    alert_state: str | None = None
+    alert_above: float | None = None
+    alert_below: float | None = None
+    last_changed: datetime | None = None
+    # Lets a consumer that fetched the unfiltered (show_on_card=False) list
+    # still pick out the card-visible subset itself, instead of issuing a
+    # second request for the same location.
+    show_on_card: bool = True

+ 65 - 3
backend/app/schemas/notification.py

@@ -3,7 +3,7 @@
 from datetime import datetime
 from typing import Any
 
-from pydantic import BaseModel, Field, field_validator
+from pydantic import BaseModel, Field, field_validator, model_validator
 
 from backend.app.core.compat import StrEnum
 
@@ -65,11 +65,17 @@ class NotificationProviderBase(BaseModel):
         default=False, description="Notify when AMS-HT temperature exceeds threshold"
     )
 
-    # Event triggers - Home Assistant sensors (#1148)
+    # Event triggers - Home Assistant sensors bound to a printer (#1148)
     on_ha_sensor_alert: bool = Field(
         default=False, description="Notify when a bound Home Assistant sensor enters its alert state"
     )
 
+    # Event triggers - Home Assistant sensors bound to a storage location (#2824)
+    on_location_ha_sensor_alert: bool = Field(
+        default=False,
+        description="Notify when a Home Assistant sensor bound to a storage location enters its alert state",
+    )
+
     # Event triggers - Build plate detection
     on_plate_not_empty: bool = Field(default=True, description="Notify when objects detected on plate before print")
     on_plate_clear_required: bool = Field(
@@ -82,6 +88,19 @@ class NotificationProviderBase(BaseModel):
     # Event triggers - First layer complete
     on_first_layer_complete: bool = Field(default=False, description="Notify when first layer completes")
 
+    # Event triggers - Inventory stock alerts
+    # Missing from this schema until now, so every payload naming them was
+    # dropped silently: the UI's toggles round-tripped as 200 OK and the row
+    # never changed, and _provider_to_dict never returned them either, so they
+    # always read back off. The columns and the sending code have existed since
+    # the inventory forecast landed.
+    on_stock_reorder_alert: bool = Field(
+        default=False, description="Notify when an inventory SKU hits its reorder point"
+    )
+    on_stock_break_alert: bool = Field(
+        default=False, description="Notify when stock will run out before replenishment arrives"
+    )
+
     # Event triggers - Print queue
     on_queue_job_added: bool = Field(default=False, description="Notify when job is added to queue")
     on_queue_job_assigned: bool = Field(default=False, description="Notify when model-based job is assigned to printer")
@@ -159,9 +178,12 @@ class NotificationProviderUpdate(BaseModel):
     on_ams_ht_humidity_high: bool | None = None
     on_ams_ht_temperature_high: bool | None = None
 
-    # Event triggers - Home Assistant sensors (#1148)
+    # Event triggers - Home Assistant sensors bound to a printer (#1148)
     on_ha_sensor_alert: bool | None = None
 
+    # Event triggers - Home Assistant sensors bound to a storage location (#2824)
+    on_location_ha_sensor_alert: bool | None = None
+
     # Event triggers - Build plate detection
     on_plate_not_empty: bool | None = None
     on_plate_clear_required: bool | None = None
@@ -172,6 +194,10 @@ class NotificationProviderUpdate(BaseModel):
     # Event triggers - First layer complete
     on_first_layer_complete: bool | None = None
 
+    # Event triggers - Inventory stock alerts
+    on_stock_reorder_alert: bool | None = None
+    on_stock_break_alert: bool | None = None
+
     # Event triggers - Print queue
     on_queue_job_added: bool | None = None
     on_queue_job_assigned: bool | None = None
@@ -197,6 +223,42 @@ class NotificationProviderUpdate(BaseModel):
 class NotificationProviderResponse(NotificationProviderBase):
     """Schema for notification provider API responses."""
 
+    @model_validator(mode="before")
+    @classmethod
+    def _null_event_flags_read_as_off(cls, data: Any) -> Any:
+        """Read a NULL event flag as off instead of failing the whole response.
+
+        Every on_* column on notification_providers is nullable with no server
+        default -- the values come from the ORM at INSERT time. A row created
+        before a flag's column existed keeps NULL there forever unless a
+        migration backfills it, and one that did not (the column was created by
+        Base.metadata before run_migrations, so the ALTER ... DEFAULT false was
+        swallowed as a duplicate) leaves NULLs behind on a live install.
+
+        Those NULLs are harmless until the flag is declared on this schema: the
+        Response inherits the write model, so `bool` is then required on the way
+        out, pydantic rejects None, and every provider row fails at once -- the
+        list route 500s and the UI renders an empty list, which reads to the user
+        as "my providers are gone". That is exactly what shipped in #2827.
+
+        Off is not a guess: _get_providers_for_event selects on `.is_(True)`, so
+        the sender already skips a NULL flag. This makes the read agree with the
+        behaviour the row already has, rather than with the field's declared
+        default -- some of which are True, and none of which should switch a
+        notification on as a side effect of repairing a legacy row.
+
+        Writes are untouched: Create and Update inherit from the base, not here,
+        so a payload sending null for a flag is still a 422.
+        """
+        # Every route returns _provider_to_dict(); anything else (an ORM object
+        # via from_attributes) is passed through for pydantic to handle.
+        if not isinstance(data, dict):
+            return data
+        flags = [name for name, f in cls.model_fields.items() if f.annotation is bool]
+        if any(data.get(name, False) is None for name in flags):
+            data = {**data, **{name: False for name in flags if data.get(name, False) is None}}
+        return data
+
     id: int
     last_success: datetime | None = None
     last_error: str | None = None

+ 8 - 0
backend/app/schemas/notification_template.py

@@ -91,6 +91,7 @@ EVENT_VARIABLES: dict[str, list[str]] = {
     ],
     "bed_cooled": ["printer", "bed_temp", "threshold", "filename", "timestamp", "app_name"],
     "ha_sensor_alert": ["printer", "sensor", "state", "timestamp", "app_name"],
+    "location_ha_sensor_alert": ["location", "sensor", "state", "timestamp", "app_name"],
     "test": ["app_name", "timestamp"],
     # Queue notifications
     "queue_job_added": ["job_name", "target", "timestamp", "app_name"],
@@ -243,6 +244,13 @@ SAMPLE_DATA: dict[str, dict[str, str]] = {
         "timestamp": "2024-01-15 14:30",
         "app_name": "Bambuddy",
     },
+    "location_ha_sensor_alert": {
+        "location": "Drybox 1",
+        "sensor": "Drybox 1 Humidity",
+        "state": "68.00 %",
+        "timestamp": "2024-01-15 14:30",
+        "app_name": "Bambuddy",
+    },
     "test": {
         "app_name": "Bambuddy",
         "timestamp": "2024-01-15 14:30",

+ 15 - 2
backend/app/schemas/printer_ha_sensor.py

@@ -9,7 +9,11 @@ from pydantic import BaseModel, Field, model_validator
 class PrinterHASensorBase(BaseModel):
     printer_id: int
     name: str = Field(..., min_length=1, max_length=100)
-    entity_id: str = Field(..., pattern=r"^(binary_sensor|sensor)\.[a-z0-9_]+$")
+    # max_length matches the column (String(255)). The pattern's [a-z0-9_]+ is
+    # unbounded, so a direct API caller could send a longer id: SQLite stores
+    # it, PostgreSQL raises DataError, and it would surface as a 500 rather
+    # than a 422. Same bound as the location sibling.
+    entity_id: str = Field(..., max_length=255, pattern=r"^(binary_sensor|sensor)\.[a-z0-9_]+$")
     kind: Literal["binary", "numeric"] = "binary"
     device_class: str | None = Field(default=None, max_length=32)
     unit: str | None = Field(default=None, max_length=16)
@@ -59,7 +63,8 @@ class PrinterHASensorUpdate(BaseModel):
     the per-kind rules above need fields this payload may not carry."""
 
     name: str | None = Field(default=None, min_length=1, max_length=100)
-    entity_id: str | None = Field(default=None, pattern=r"^(binary_sensor|sensor)\.[a-z0-9_]+$")
+    # Same column-width bound as the base schema; PATCH reaches the same row.
+    entity_id: str | None = Field(default=None, max_length=255, pattern=r"^(binary_sensor|sensor)\.[a-z0-9_]+$")
     kind: Literal["binary", "numeric"] | None = None
     device_class: str | None = Field(default=None, max_length=32)
     unit: str | None = Field(default=None, max_length=16)
@@ -73,6 +78,14 @@ class PrinterHASensorUpdate(BaseModel):
 
 
 class PrinterHASensorResponse(PrinterHASensorBase):
+    # Reads stay tolerant of what writes now reject: this feature shipped
+    # before entity_id was bounded, and SQLite never enforced the column's 255,
+    # so a row longer than that can genuinely exist. Inheriting the bound would
+    # turn it into a 500 on the list route — the same failure the bound was
+    # added to prevent, moved from the write path to the read path. The pattern
+    # is dropped with it, for the same generation of rows. Writes are unchanged.
+    entity_id: str
+
     id: int
     last_state: str | None = None
     last_changed: datetime | None = None

+ 27 - 0
backend/app/schemas/settings.py

@@ -606,6 +606,27 @@ class AppSettings(BaseModel):
         description="Global lead time floor (days) used in reorder point calculation for all SKUs",
     )
 
+    location_sensor_poll_interval: int = Field(
+        default=120,
+        ge=60,
+        le=3600,
+        description="Seconds between Home Assistant polls/UI refreshes for storage-location sensors",
+    )
+    # Server-backed rather than per-browser: these seed the alert rule written
+    # onto each sensor row when one is bound, so two admins binding sensors
+    # from different browsers must not seed different rules — and a restore
+    # has to bring them back. The "show on card" default stays local, because
+    # show_on_card is decided per sensor and this is only its form
+    # pre-selection. Same JSON-in-a-string shape as preheat_filament_targets.
+    location_sensor_alert_defaults: str = Field(
+        default="",
+        description=(
+            "JSON map of sensor category (temperature/humidity/battery) → "
+            '{"alertAbove": str, "alertBelow": str, "notifyOnAlert": bool}, seeding new '
+            "storage-location sensor bindings. Empty = built-in defaults."
+        ),
+    )
+
     # Default sidebar order (admin-set for all users)
     default_sidebar_order: str = Field(
         default="",
@@ -748,6 +769,12 @@ class AppSettingsUpdate(BaseModel):
     obico_enabled_printers: str | None = None
     default_sidebar_order: str | None = None
     forecast_global_lead_time_days: int | None = Field(default=None, ge=0)
+    location_sensor_poll_interval: int | None = Field(default=None, ge=60, le=3600)
+    # Three categories × three short fields is well under 300 characters of
+    # JSON, so 2000 is pure headroom — the cap only stops a stray client from
+    # parking megabytes in the settings table. Write path only: the AppSettings
+    # read model must keep accepting whatever an older install already stored.
+    location_sensor_alert_defaults: str | None = Field(default=None, max_length=2000)
 
     @field_validator(*LAN_SERVICE_URL_SETTINGS)
     @classmethod

+ 47 - 7
backend/app/services/ha_sensor_manager.py

@@ -18,12 +18,13 @@ door contact that stops responding must not strand the queue.
 import asyncio
 import logging
 from dataclasses import dataclass
+from typing import Protocol
 
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.models.printer import Printer
-from backend.app.models.printer_ha_sensor import PrinterHASensor
+from backend.app.models.printer_ha_sensor import LAST_STATE_MAX_LENGTH, PrinterHASensor
 from backend.app.services.homeassistant import as_float, homeassistant_service
 from backend.app.utils.local_time import utcnow_naive
 
@@ -44,6 +45,25 @@ class SensorReading:
     reachable: bool
 
 
+def persistable_state(state: str | None, max_length: int) -> str | None:
+    """Fit a raw HA state into a last_state column.
+
+    A numeric entity can start reporting free text (an enum, an error string)
+    longer than the column. PostgreSQL rejects the oversized row, and since a
+    poll pass commits every sensor at once, one such entity would sink every
+    other sensor's update on every tick -- and for printer sensors that also
+    freezes the print interlock's view of the world.
+
+    The cached SensorReading keeps the full state; only what is persisted is
+    cut, and the comparison against the stored value is done on the cut form so
+    an unchanged-but-long state does not read as a change on every poll.
+
+    Shared with the storage-location poller, which has the same column on its
+    own table -- each caller passes its own model's width.
+    """
+    return state[:max_length] if state else state
+
+
 class HASensorManager:
     def __init__(self):
         self._task: asyncio.Task | None = None
@@ -162,8 +182,9 @@ class HASensorManager:
             self._last_alerting[sensor.id] = reading.alerting
 
         sensor.last_checked = utcnow_naive()
-        if reading.reachable and sensor.last_state != reading.state:
-            sensor.last_state = reading.state
+        persisted = persistable_state(reading.state, LAST_STATE_MAX_LENGTH)
+        if reading.reachable and sensor.last_state != persisted:
+            sensor.last_state = persisted
             sensor.last_changed = sensor.last_checked
         await db.commit()
         await db.refresh(sensor)
@@ -196,8 +217,9 @@ class HASensorManager:
 
             sensor.last_checked = now
             if reading.reachable:
-                if sensor.last_state != reading.state:
-                    sensor.last_state = reading.state
+                persisted = persistable_state(reading.state, LAST_STATE_MAX_LENGTH)
+                if sensor.last_state != persisted:
+                    sensor.last_state = persisted
                     sensor.last_changed = now
 
             # Notify on the edge into alerting only. `was_alerting is None` is
@@ -228,7 +250,25 @@ class HASensorManager:
                 logger.warning("Failed to send HA sensor alert for '%s': %s", sensor.name, e)
 
 
-def evaluate(sensor: PrinterHASensor, payload: dict | None) -> SensorReading:
+class _AlertableSensor(Protocol):
+    """Structural type for evaluate()/describe_state().
+
+    PrinterHASensor and LocationHASensor are unrelated SQLAlchemy models —
+    one has no base class in common with the other beyond ``Base`` — but both
+    carry these five fields with the same meaning, and location_ha_sensor_
+    manager.py imports these two functions to reuse the exact same alert
+    logic rather than reimplementing it. A concrete PrinterHASensor
+    annotation here would be a lie for half of the actual callers.
+    """
+
+    kind: str
+    unit: str | None
+    alert_state: str | None
+    alert_above: float | None
+    alert_below: float | None
+
+
+def evaluate(sensor: _AlertableSensor, payload: dict | None) -> SensorReading:
     """Turn one HA state payload into a reading.
 
     Split out from the manager so the alert rules can be tested without a
@@ -261,7 +301,7 @@ def evaluate(sensor: PrinterHASensor, payload: dict | None) -> SensorReading:
     return SensorReading(state=normalized, value=None, alerting=alerting, reachable=True)
 
 
-def describe_state(sensor: PrinterHASensor, reading: SensorReading) -> str:
+def describe_state(sensor: _AlertableSensor, reading: SensorReading) -> str:
     """Human-readable state for a notification body ("open", "31.4 °C")."""
     if sensor.kind == "numeric" and reading.value is not None:
         return f"{reading.value:g} {sensor.unit}".strip() if sensor.unit else f"{reading.value:g}"

+ 216 - 0
backend/app/services/location_ha_sensor_manager.py

@@ -0,0 +1,216 @@
+import asyncio
+import logging
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.location import Location
+from backend.app.models.location_ha_sensor import LAST_STATE_MAX_LENGTH, LocationHASensor
+from backend.app.models.settings import Settings
+from backend.app.services.ha_sensor_manager import SensorReading, describe_state, evaluate, persistable_state
+from backend.app.services.homeassistant import homeassistant_service
+from backend.app.utils.local_time import utcnow_naive
+
+logger = logging.getLogger(__name__)
+
+POLL_INTERVAL = 120
+MIN_POLL_INTERVAL = 60
+
+
+class LocationHASensorManager:
+    def __init__(self):
+        self._task: asyncio.Task | None = None
+        # sensor id -> last reading. Sensors absent from this map have not been
+        # polled yet; callers must not read that as "not alerting" without also
+        # checking, which is why get_reading returns None rather than a default.
+        self._readings: dict[int, SensorReading] = {}
+        # sensor id -> alerting, from the last reading we could actually take.
+        # Kept apart from _readings because a dropout must not read as the
+        # alert clearing: on -> unavailable -> on is one continuous alert, and
+        # notifying off _readings alone would ping the user on every reconnect
+        # of a flaky sensor. Absent means "never had a reachable reading".
+        self._last_alerting: dict[int, bool] = {}
+
+    def start(self):
+        if self._task is None:
+            self._task = asyncio.create_task(self._poll_loop())
+            logger.info("Home Assistant location-sensor poller started")
+
+    def stop(self):
+        if self._task:
+            self._task.cancel()
+            self._task = None
+            logger.info("Home Assistant location-sensor poller stopped")
+
+    def get_reading(self, sensor_id: int) -> SensorReading | None:
+        return self._readings.get(sensor_id)
+
+    def forget(self, sensor_id: int):
+        """Drop a deleted sensor's cached reading so its id cannot be reused
+        by a later row and answer with the old sensor's state."""
+        self._readings.pop(sensor_id, None)
+        self._last_alerting.pop(sensor_id, None)
+
+    async def _poll_loop(self):
+        # Poll first, sleep after — the interval is configurable and can be
+        # minutes long, and a restart should not leave every location's
+        # reading blank on the card for a full interval before the first one
+        # lands.
+        while True:
+            try:
+                await self.poll_once()
+            except asyncio.CancelledError:
+                break
+            except Exception as e:
+                logger.warning("Home Assistant location-sensor poll failed: %s", e)
+            try:
+                await asyncio.sleep(await self._get_poll_interval())
+            except asyncio.CancelledError:
+                break
+            except Exception as e:
+                # _get_poll_interval() reads Settings, so this leg does I/O
+                # and a transient database failure (pool exhaustion, a
+                # restarting server) can raise here. Letting it escape ends
+                # the task for good: stop() is what clears self._task, so a
+                # loop that died on its own leaves it set and start() will
+                # not revive it — location sensors would stay frozen until
+                # the process restarts. The poll_once() call above already
+                # survives the same error one line earlier.
+                logger.warning("Home Assistant location-sensor poll interval lookup failed: %s", e)
+                await asyncio.sleep(POLL_INTERVAL)
+
+    async def _get_poll_interval(self) -> int:
+        """User-configurable poll cadence, clamped to a sane floor.
+
+        Falls back to the default on a missing row or a corrupted value
+        rather than raising — a bad setting must not take the poller down.
+        """
+        from backend.app.core.database import async_session
+
+        async with async_session() as db:
+            result = await db.execute(select(Settings).where(Settings.key == "location_sensor_poll_interval"))
+            row = result.scalar_one_or_none()
+        if row is None:
+            return POLL_INTERVAL
+        try:
+            return max(MIN_POLL_INTERVAL, int(row.value))
+        except (TypeError, ValueError):
+            return POLL_INTERVAL
+
+    async def poll_once(self):
+        """One pass over every configured sensor."""
+        from backend.app.core.database import async_session
+
+        async with async_session() as db:
+            result = await db.execute(select(LocationHASensor))
+            sensors = list(result.scalars().all())
+
+            # Drop readings for rows that no longer exist. The delete route
+            # calls forget(), but a location deleted with sensors attached
+            # takes them out by cascade, and a restored backup can renumber
+            # them — either way a stale id must not answer for a later sensor.
+            live = {s.id for s in sensors}
+            for stale in set(self._readings) - live:
+                self.forget(stale)
+
+            if not sensors:
+                return
+
+            if not await self._configure(db):
+                for sensor in sensors:
+                    self._readings[sensor.id] = SensorReading(None, None, False, False)
+                return
+
+            states = await homeassistant_service.fetch_states(sorted({s.entity_id for s in sensors}))
+            await self._apply(db, sensors, states)
+
+    async def refresh_one(self, db: AsyncSession, sensor: LocationHASensor):
+        """Read a single sensor now, on the caller's session.
+
+        Used after a create or an edit so the card shows a state straight away
+        instead of blank until the next tick. Deliberately not a full
+        ``poll_once``: a request handler must not wait on every configured
+        entity, and must not fire another user's notification as a side effect
+        of this one saving a form.
+        """
+        self.forget(sensor.id)
+        if not await self._configure(db):
+            self._readings[sensor.id] = SensorReading(None, None, False, False)
+            return
+
+        states = await homeassistant_service.fetch_states([sensor.entity_id])
+        reading = evaluate(sensor, states.get(sensor.entity_id))
+        self._readings[sensor.id] = reading
+        if reading.reachable:
+            self._last_alerting[sensor.id] = reading.alerting
+
+        sensor.last_checked = utcnow_naive()
+        persisted = persistable_state(reading.state, LAST_STATE_MAX_LENGTH)
+        if reading.reachable and sensor.last_state != persisted:
+            sensor.last_state = persisted
+            sensor.last_changed = sensor.last_checked
+        await db.commit()
+        await db.refresh(sensor)
+
+    async def _configure(self, db: AsyncSession) -> bool:
+        from backend.app.api.routes.settings import get_homeassistant_settings
+
+        try:
+            ha_settings = await get_homeassistant_settings(db)
+        except Exception as e:
+            logger.warning("Failed to read Home Assistant settings: %s", e)
+            return False
+        if not ha_settings["ha_url"] or not ha_settings["ha_token"]:
+            return False
+        homeassistant_service.configure(ha_settings["ha_url"], ha_settings["ha_token"])
+        return True
+
+    async def _apply(self, db: AsyncSession, sensors: list[LocationHASensor], states: dict[str, dict | None]):
+        """Fold poll results into the cache, the DB and any notifications."""
+        from backend.app.services.notification_service import notification_service
+
+        now = utcnow_naive()
+        alerts: list[tuple[LocationHASensor, SensorReading]] = []
+
+        for sensor in sensors:
+            payload = states.get(sensor.entity_id)
+            reading = evaluate(sensor, payload)
+            was_alerting = self._last_alerting.get(sensor.id)
+            self._readings[sensor.id] = reading
+
+            sensor.last_checked = now
+            if reading.reachable:
+                persisted = persistable_state(reading.state, LAST_STATE_MAX_LENGTH)
+                if sensor.last_state != persisted:
+                    sensor.last_state = persisted
+                    sensor.last_changed = now
+
+            # Notify on the edge into alerting only. `was_alerting is None` is
+            # a cold cache (first poll after a restart) — a drybox that was
+            # already too humid then has not just become too humid, and
+            # re-announcing it on every restart would train users to ignore
+            # the alert.
+            if sensor.notify_on_alert and reading.reachable and reading.alerting and was_alerting is False:
+                alerts.append((sensor, reading))
+
+            if reading.reachable:
+                self._last_alerting[sensor.id] = reading.alerting
+
+        await db.commit()
+
+        for sensor, reading in alerts:
+            # db.get, not sensor.location: touching the lazy relationship from
+            # an async session raises MissingGreenlet.
+            location = await db.get(Location, sensor.location_id)
+            try:
+                await notification_service.on_location_ha_sensor_alert(
+                    location_name=location.name if location else "Unknown",
+                    sensor_name=sensor.name,
+                    state=describe_state(sensor, reading),
+                    db=db,
+                )
+            except Exception as e:
+                logger.warning("Failed to send HA sensor alert for '%s': %s", sensor.name, e)
+
+
+location_ha_sensor_manager = LocationHASensorManager()

+ 38 - 0
backend/app/services/notification_service.py

@@ -1869,6 +1869,44 @@ class NotificationService:
             variables=variables,
         )
 
+    async def on_location_ha_sensor_alert(
+        self,
+        location_name: str,
+        sensor_name: str,
+        state: str,
+        db: AsyncSession,
+    ):
+        """A Home Assistant sensor bound to a storage location entered its alert state (#2824).
+
+        Sent immediately rather than folded into a digest, for the same reason
+        as on_ha_sensor_alert above: this is the "drybox went stale" case, only
+        worth acting on while the humidity/temperature is still climbing.
+        """
+        # Own column, not on_ha_sensor_alert (#2824): that one can be scoped to
+        # a single printer via provider.printer_id, and a location alert has no
+        # printer to scope by, so sharing it would leak drybox alerts to a
+        # provider narrowed to one printer's sensors.
+        providers = await self._get_providers_for_event(db, "on_location_ha_sensor_alert", None)
+        if not providers:
+            return
+
+        variables = {
+            "location": location_name,
+            "sensor": sensor_name,
+            "state": state,
+        }
+
+        title, message = await self._build_message_from_template(db, "location_ha_sensor_alert", variables)
+        await self._send_to_providers(
+            providers,
+            title,
+            message,
+            db,
+            "location_ha_sensor_alert",
+            force_immediate=True,
+            variables=variables,
+        )
+
     async def on_first_layer_complete(
         self,
         printer_id: int,

+ 20 - 0
backend/app/utils/natural_sort.py

@@ -0,0 +1,20 @@
+"""Natural (numeric-aware) string sorting, e.g. "Drybox 2" before "Drybox 10"."""
+
+import re
+
+_CHUNK_RE = re.compile(r"(\d+)")
+
+
+def natural_sort_key(value: str) -> tuple:
+    """Sort key that orders embedded numbers by value, not lexicographically.
+
+    A plain string sort puts "Drybox 10" before "Drybox 2" (character by
+    character, "1" < "2"). Splitting into alternating text/digit runs and
+    comparing the digit runs as integers instead gets "Drybox 2" before
+    "Drybox 10", without assuming every name follows a fixed "prefix N"
+    shape. `_CHUNK_RE.split` always yields text chunks at even indices and
+    digit chunks at odd indices for any input, so the type at a given index
+    is consistent across every key this function produces — two keys can be
+    compared without ever hitting a str-vs-int mismatch mid-tuple.
+    """
+    return tuple(int(chunk) if chunk.isdigit() else chunk.lower() for chunk in _CHUNK_RE.split(value))

+ 26 - 0
backend/tests/conftest.py

@@ -588,6 +588,32 @@ def printer_factory(db_session):
     return _create_printer
 
 
+@pytest.fixture
+def location_factory(db_session):
+    _counter = [0]
+
+    async def _create_location(**kwargs):
+        from backend.app.models.location import Location
+
+        _counter[0] += 1
+        counter = _counter[0]
+
+        name = kwargs.pop("name", f"Test Location {counter}")
+        defaults = {
+            "name": name,
+            "name_key": name.strip().lower(),
+        }
+        defaults.update(kwargs)
+
+        location = Location(**defaults)
+        db_session.add(location)
+        await db_session.commit()
+        await db_session.refresh(location)
+        return location
+
+    return _create_location
+
+
 @pytest.fixture
 def notification_provider_factory(db_session):
     """Factory to create test notification providers."""

+ 47 - 0
backend/tests/integration/test_ha_sensors_api_1148.py

@@ -54,6 +54,53 @@ class TestCrud:
         assert body["block_print"] is False
         assert body["notify_on_alert"] is False
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rejects_an_entity_id_longer_than_the_column(self, async_client: AsyncClient, printer_factory):
+        """Same column-width bound as the location sibling: the pattern alone
+        is unbounded, and an oversized id would be a 500 on PostgreSQL."""
+        printer = await printer_factory()
+
+        response = await async_client.post(
+            "/api/v1/ha-sensors/",
+            json={**DOOR, "printer_id": printer.id, "entity_id": "binary_sensor." + "a" * 400},
+        )
+
+        assert response.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_row_that_predates_the_bound_is_still_readable(
+        self, async_client: AsyncClient, db_session, printer_factory
+    ):
+        """The bound guards writes; it must not turn old rows into a 500.
+
+        This feature shipped long before entity_id was bounded, and SQLite
+        never enforced the column's 255, so an install that took a long id
+        through the API has that row today. Inheriting the bound on the
+        response model would fail response validation and take the whole list
+        down for one row -- the same 500 the bound was added to prevent, moved
+        to the read path.
+        """
+        from sqlalchemy import text
+
+        printer = await printer_factory()
+        created = await async_client.post("/api/v1/ha-sensors/", json={**DOOR, "printer_id": printer.id})
+        # Same domain as the row's kind: the response model still derives the
+        # expected kind from the id, so only the length and pattern are relaxed.
+        legacy_id = "binary_sensor." + "a" * 400
+
+        await db_session.execute(
+            text("UPDATE printer_ha_sensors SET entity_id = :e WHERE id = :i"),
+            {"e": legacy_id, "i": created.json()["id"]},
+        )
+        await db_session.commit()
+
+        response = await async_client.get(f"/api/v1/ha-sensors/?printer_id={printer.id}")
+
+        assert response.status_code == 200
+        assert response.json()[0]["entity_id"] == legacy_id
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_rejects_a_switch(self, async_client: AsyncClient, printer_factory):

+ 570 - 0
backend/tests/integration/test_location_ha_sensors_api.py

@@ -0,0 +1,570 @@
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from httpx import AsyncClient
+
+from backend.app.services.ha_sensor_manager import SensorReading
+from backend.app.services.location_ha_sensor_manager import location_ha_sensor_manager
+
+HUMIDITY = {
+    "name": "Drybox Humidity",
+    "entity_id": "sensor.drybox_humidity",
+    "kind": "numeric",
+    "device_class": "humidity",
+    "unit": "%",
+}
+DOOR = {
+    "name": "Cabinet Door",
+    "entity_id": "binary_sensor.cabinet_door",
+    "kind": "binary",
+    "device_class": "door",
+    "alert_state": "on",
+}
+
+
+@pytest.fixture(autouse=True)
+def _no_live_ha():
+    with patch.object(location_ha_sensor_manager, "refresh_one", AsyncMock()):
+        yield
+
+
+@pytest.fixture(autouse=True)
+def _clean_cache():
+    yield
+    location_ha_sensor_manager._readings.clear()
+    location_ha_sensor_manager._last_alerting.clear()
+
+
+class TestCrud:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bind_a_humidity_sensor(self, async_client: AsyncClient, location_factory):
+        location = await location_factory()
+
+        response = await async_client.post(
+            "/api/v1/location-ha-sensors/", json={**HUMIDITY, "location_id": location.id}
+        )
+
+        assert response.status_code == 200
+        body = response.json()
+        assert body["entity_id"] == "sensor.drybox_humidity"
+        assert body["kind"] == "numeric"
+        assert body["show_on_card"] is True
+        assert body["notify_on_alert"] is False
+        assert "block_print" not in body
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rejects_a_switch(self, async_client: AsyncClient, location_factory):
+        location = await location_factory()
+
+        response = await async_client.post(
+            "/api/v1/location-ha-sensors/",
+            json={**DOOR, "location_id": location.id, "entity_id": "switch.something"},
+        )
+
+        assert response.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rejects_a_kind_that_contradicts_the_entity(self, async_client: AsyncClient, location_factory):
+        location = await location_factory()
+
+        response = await async_client.post(
+            "/api/v1/location-ha-sensors/",
+            json={**HUMIDITY, "location_id": location.id, "kind": "binary"},
+        )
+
+        assert response.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rejects_a_notify_with_nothing_to_trigger_on(self, async_client: AsyncClient, location_factory):
+        location = await location_factory()
+
+        response = await async_client.post(
+            "/api/v1/location-ha-sensors/",
+            json={**DOOR, "location_id": location.id, "alert_state": None, "notify_on_alert": True},
+        )
+
+        assert response.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rejects_a_duplicate_binding(self, async_client: AsyncClient, location_factory):
+        location = await location_factory()
+        payload = {**HUMIDITY, "location_id": location.id}
+        await async_client.post("/api/v1/location-ha-sensors/", json=payload)
+
+        response = await async_client.post("/api/v1/location-ha-sensors/", json=payload)
+
+        assert response.status_code == 400
+        assert "already bound" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rejects_an_entity_id_longer_than_the_column(self, async_client: AsyncClient, location_factory):
+        """entity_id is bounded by max_length, not just by its pattern.
+
+        The pattern's [a-z0-9_]+ is unbounded, so a direct API caller could
+        exceed the String(255) column. SQLite stores it regardless, but
+        PostgreSQL raises DataError, and the route only maps IntegrityError —
+        it would come back as a 500 instead of a 422.
+        """
+        location = await location_factory()
+
+        response = await async_client.post(
+            "/api/v1/location-ha-sensors/",
+            json={**HUMIDITY, "location_id": location.id, "entity_id": "sensor." + "a" * 400},
+        )
+
+        assert response.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_patch_rejects_an_entity_id_longer_than_the_column(self, async_client: AsyncClient, location_factory):
+        location = await location_factory()
+        created = await async_client.post("/api/v1/location-ha-sensors/", json={**HUMIDITY, "location_id": location.id})
+
+        response = await async_client.patch(
+            f"/api/v1/location-ha-sensors/{created.json()['id']}",
+            json={"entity_id": "sensor." + "b" * 400},
+        )
+
+        assert response.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_row_that_predates_the_bound_is_still_readable(
+        self, async_client: AsyncClient, db_session, location_factory
+    ):
+        """The bound guards writes; it must not turn old rows into a 500.
+
+        SQLite never enforced the column's 255, so an install that took a
+        long entity_id through the API before max_length existed has that row
+        today. Inheriting the bound on the response model would fail response
+        validation and take the whole list down for one row -- the same 500
+        the bound was added to prevent, moved to the read path.
+        """
+        from sqlalchemy import text
+
+        location = await location_factory()
+        created = await async_client.post("/api/v1/location-ha-sensors/", json={**HUMIDITY, "location_id": location.id})
+        legacy_id = "sensor." + "a" * 400
+
+        await db_session.execute(
+            text("UPDATE location_ha_sensors SET entity_id = :e WHERE id = :i"),
+            {"e": legacy_id, "i": created.json()["id"]},
+        )
+        await db_session.commit()
+
+        response = await async_client.get(f"/api/v1/location-ha-sensors/?location_id={location.id}")
+
+        assert response.status_code == 200
+        assert response.json()[0]["entity_id"] == legacy_id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rejects_an_unknown_location(self, async_client: AsyncClient):
+        response = await async_client.post("/api/v1/location-ha-sensors/", json={**HUMIDITY, "location_id": 9999})
+
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_patch_accepts_a_coherent_change(self, async_client: AsyncClient, location_factory):
+        location = await location_factory()
+        created = await async_client.post("/api/v1/location-ha-sensors/", json={**HUMIDITY, "location_id": location.id})
+        sensor_id = created.json()["id"]
+
+        response = await async_client.patch(
+            f"/api/v1/location-ha-sensors/{sensor_id}",
+            json={"alert_above": 60, "notify_on_alert": True, "name": "Box Humidity"},
+        )
+
+        assert response.status_code == 200
+        assert response.json()["notify_on_alert"] is True
+        assert response.json()["name"] == "Box Humidity"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_drops_the_cached_reading(self, async_client: AsyncClient, location_factory):
+        location = await location_factory()
+        created = await async_client.post("/api/v1/location-ha-sensors/", json={**HUMIDITY, "location_id": location.id})
+        sensor_id = created.json()["id"]
+        location_ha_sensor_manager._readings[sensor_id] = SensorReading("41.2", 41.2, True, True)
+
+        response = await async_client.delete(f"/api/v1/location-ha-sensors/{sensor_id}")
+
+        assert response.status_code == 200
+        assert location_ha_sensor_manager.get_reading(sensor_id) is None
+
+
+class TestReadings:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_serves_the_cached_reading(self, async_client: AsyncClient, location_factory):
+        location = await location_factory()
+        created = await async_client.post(
+            "/api/v1/location-ha-sensors/", json={**HUMIDITY, "location_id": location.id, "alert_above": 60}
+        )
+        sensor_id = created.json()["id"]
+        location_ha_sensor_manager._readings[sensor_id] = SensorReading("65.0", 65.0, True, True)
+
+        response = await async_client.get(f"/api/v1/location-ha-sensors/by-location/{location.id}/readings")
+
+        assert response.status_code == 200
+        reading = response.json()[0]
+        assert reading["value"] == 65.0
+        assert reading["alerting"] is True
+        assert reading["unit"] == "%"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_hidden_sensors_stay_off_the_card(self, async_client: AsyncClient, location_factory):
+        location = await location_factory()
+        await async_client.post(
+            "/api/v1/location-ha-sensors/",
+            json={**HUMIDITY, "location_id": location.id, "show_on_card": False},
+        )
+
+        response = await async_client.get(f"/api/v1/location-ha-sensors/by-location/{location.id}/readings")
+
+        assert response.json() == []
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_hidden_sensors_are_included_when_not_restricted_to_the_card(
+        self, async_client: AsyncClient, location_factory
+    ):
+        location = await location_factory()
+        await async_client.post(
+            "/api/v1/location-ha-sensors/",
+            json={**HUMIDITY, "location_id": location.id, "show_on_card": False},
+        )
+
+        response = await async_client.get(
+            f"/api/v1/location-ha-sensors/by-location/{location.id}/readings?show_on_card=false"
+        )
+
+        assert len(response.json()) == 1
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_other_locations_sensors_are_not_listed(self, async_client: AsyncClient, location_factory):
+        one = await location_factory()
+        two = await location_factory()
+        await async_client.post("/api/v1/location-ha-sensors/", json={**HUMIDITY, "location_id": one.id})
+
+        response = await async_client.get(f"/api/v1/location-ha-sensors/by-location/{two.id}/readings")
+
+        assert response.json() == []
+
+
+class TestEntityPicker:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_explains_itself_when_ha_is_not_configured(self, async_client: AsyncClient):
+        response = await async_client.get("/api/v1/location-ha-sensors/entities")
+
+        assert response.status_code == 400
+        assert "Home Assistant not configured" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_entities_is_not_parsed_as_a_sensor_id(self, async_client: AsyncClient):
+        response = await async_client.get("/api/v1/location-ha-sensors/entities")
+
+        assert response.status_code != 404
+
+
+class TestCascadeAndUniqueness:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_patch_cannot_create_a_duplicate_binding(self, async_client: AsyncClient, location_factory):
+        location = await location_factory()
+        await async_client.post("/api/v1/location-ha-sensors/", json={**DOOR, "location_id": location.id})
+        second = await async_client.post("/api/v1/location-ha-sensors/", json={**HUMIDITY, "location_id": location.id})
+
+        response = await async_client.patch(
+            f"/api/v1/location-ha-sensors/{second.json()['id']}",
+            json={"entity_id": DOOR["entity_id"], "kind": "binary"},
+        )
+
+        assert response.status_code == 400
+        assert "already bound" in response.json()["detail"]
+
+    # One sensor per category per location (#2824 review). The card footer and
+    # the inventory column each pick their reading with a single `find`, so a
+    # second sensor of the same category silently shadows the first instead of
+    # appearing next to it. The modal prompts to replace; these cover the same
+    # rule for a direct API caller.
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rejects_a_second_sensor_of_the_same_category(self, async_client: AsyncClient, location_factory):
+        location = await location_factory()
+        await async_client.post("/api/v1/location-ha-sensors/", json={**HUMIDITY, "location_id": location.id})
+
+        response = await async_client.post(
+            "/api/v1/location-ha-sensors/",
+            json={
+                **HUMIDITY,
+                "location_id": location.id,
+                "name": "Second Humidity",
+                "entity_id": "sensor.drybox_humidity_two",
+            },
+        )
+
+        assert response.status_code == 400
+        assert "humidity" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_moisture_does_not_collide_with_humidity(self, async_client: AsyncClient, location_factory):
+        """ "moisture" is binary wet/dry, not a humidity percentage.
+
+        Treating it as the humidity category let a leak detector block the
+        hygrometer on the same location, put "wet" in a percent-formatted
+        column, and promised it thresholds the schema rejects for a binary
+        sensor. It has no category, so it does not take part in this rule.
+        """
+        location = await location_factory()
+        await async_client.post("/api/v1/location-ha-sensors/", json={**HUMIDITY, "location_id": location.id})
+
+        response = await async_client.post(
+            "/api/v1/location-ha-sensors/",
+            json={
+                "location_id": location.id,
+                "name": "Drybox Leak",
+                "entity_id": "binary_sensor.drybox_moisture",
+                "kind": "binary",
+                "device_class": "moisture",
+                "alert_state": "on",
+            },
+        )
+
+        assert response.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_allows_a_different_category_on_the_same_location(self, async_client: AsyncClient, location_factory):
+        """The auto-bind flow adds temperature/humidity/battery siblings together."""
+        location = await location_factory()
+        await async_client.post("/api/v1/location-ha-sensors/", json={**HUMIDITY, "location_id": location.id})
+
+        response = await async_client.post(
+            "/api/v1/location-ha-sensors/",
+            json={
+                **HUMIDITY,
+                "location_id": location.id,
+                "name": "Drybox Temperature",
+                "entity_id": "sensor.drybox_temperature",
+                "device_class": "temperature",
+                "unit": "°C",
+            },
+        )
+
+        assert response.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_same_category_on_another_location_is_fine(self, async_client: AsyncClient, location_factory):
+        one = await location_factory()
+        two = await location_factory()
+        await async_client.post("/api/v1/location-ha-sensors/", json={**HUMIDITY, "location_id": one.id})
+
+        response = await async_client.post("/api/v1/location-ha-sensors/", json={**HUMIDITY, "location_id": two.id})
+
+        assert response.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_repointing_a_sensor_within_its_own_category_still_works(
+        self, async_client: AsyncClient, location_factory
+    ):
+        """The modal's replace flow PATCHes the existing row — it must not hit its own rule."""
+        location = await location_factory()
+        created = await async_client.post("/api/v1/location-ha-sensors/", json={**HUMIDITY, "location_id": location.id})
+
+        response = await async_client.patch(
+            f"/api/v1/location-ha-sensors/{created.json()['id']}",
+            json={"entity_id": "sensor.other_humidity", "device_class": "humidity"},
+        )
+
+        assert response.status_code == 200
+        assert response.json()["entity_id"] == "sensor.other_humidity"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_patch_cannot_collide_with_another_sensors_category(
+        self, async_client: AsyncClient, location_factory
+    ):
+        location = await location_factory()
+        await async_client.post("/api/v1/location-ha-sensors/", json={**HUMIDITY, "location_id": location.id})
+        temperature = await async_client.post(
+            "/api/v1/location-ha-sensors/",
+            json={
+                **HUMIDITY,
+                "location_id": location.id,
+                "name": "Drybox Temperature",
+                "entity_id": "sensor.drybox_temperature",
+                "device_class": "temperature",
+                "unit": "°C",
+            },
+        )
+
+        response = await async_client.patch(
+            f"/api/v1/location-ha-sensors/{temperature.json()['id']}",
+            json={"device_class": "humidity"},
+        )
+
+        assert response.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_deleting_a_location_takes_its_sensors(self, async_client: AsyncClient, location_factory):
+        location = await location_factory()
+        await async_client.post("/api/v1/location-ha-sensors/", json={**DOOR, "location_id": location.id})
+
+        deleted = await async_client.delete(f"/api/v1/inventory/locations/{location.id}")
+
+        assert deleted.status_code == 200
+        listed = await async_client.get("/api/v1/location-ha-sensors/")
+        assert listed.json() == []
+
+
+class TestThresholdValidation:
+    """NaN/Infinity must not get into the alert thresholds.
+
+    Pydantic's lax mode coerces the strings "nan"/"inf" into real floats. A
+    NaN threshold satisfies "notify_on_alert requires an alert condition" yet
+    every comparison against it is False — a notification that can never fire
+    — and it slips past the below-vs-above ordering check the same way, while
+    responses serialize it as null so the UI shows an empty field.
+    """
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    @pytest.mark.parametrize("bad", ["nan", "inf", "-inf", "Infinity"])
+    async def test_create_rejects_non_finite_thresholds(self, async_client: AsyncClient, location_factory, bad):
+        location = await location_factory()
+
+        response = await async_client.post(
+            "/api/v1/location-ha-sensors/",
+            json={**HUMIDITY, "location_id": location.id, "alert_above": bad},
+        )
+
+        assert response.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_patch_rejects_non_finite_thresholds(self, async_client: AsyncClient, location_factory):
+        location = await location_factory()
+        created = await async_client.post("/api/v1/location-ha-sensors/", json={**HUMIDITY, "location_id": location.id})
+
+        response = await async_client.patch(
+            f"/api/v1/location-ha-sensors/{created.json()['id']}",
+            json={"alert_below": "nan"},
+        )
+
+        assert response.status_code == 422
+
+
+class TestUniqueBindingBackstop:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_the_database_itself_rejects_a_duplicate_binding(
+        self, async_client: AsyncClient, location_factory, db_session
+    ):
+        """The route's duplicate check is read-then-insert; the unique index is
+        what stops the race where two concurrent creates both pass it."""
+        from sqlalchemy.exc import IntegrityError
+
+        from backend.app.models.location_ha_sensor import LocationHASensor
+
+        location = await location_factory()
+        db_session.add(LocationHASensor(location_id=location.id, name="First", entity_id="sensor.x", kind="numeric"))
+        await db_session.commit()
+
+        db_session.add(LocationHASensor(location_id=location.id, name="Second", entity_id="sensor.x", kind="numeric"))
+        with pytest.raises(IntegrityError):
+            await db_session.commit()
+        await db_session.rollback()
+
+
+class TestAlertDefaultsSetting:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_accepts_a_real_defaults_map(self, async_client: AsyncClient):
+        value = '{"humidity": {"alertAbove": "60", "alertBelow": "", "notifyOnAlert": true}}'
+
+        response = await async_client.put("/api/v1/settings/", json={"location_sensor_alert_defaults": value})
+
+        assert response.status_code == 200
+        fetched = await async_client.get("/api/v1/settings/")
+        assert fetched.json()["location_sensor_alert_defaults"] == value
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_caps_the_stored_length(self, async_client: AsyncClient):
+        """The real payload is three categories × three short fields — well
+        under 300 characters. The cap only stops a stray client from parking
+        megabytes in the settings table; the frontend already treats anything
+        unparseable as "use the built-ins"."""
+        response = await async_client.put("/api/v1/settings/", json={"location_sensor_alert_defaults": "x" * 2001})
+
+        assert response.status_code == 422
+
+
+class TestPollInterval:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_defaults_to_120_seconds(self, async_client: AsyncClient):
+        interval = await location_ha_sensor_manager._get_poll_interval()
+
+        assert interval == 120
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_reads_the_configured_value(self, async_client: AsyncClient):
+        response = await async_client.put("/api/v1/settings/", json={"location_sensor_poll_interval": 300})
+        assert response.status_code == 200
+
+        interval = await location_ha_sensor_manager._get_poll_interval()
+
+        assert interval == 300
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rejects_a_value_below_the_60s_minimum(self, async_client: AsyncClient):
+        response = await async_client.put("/api/v1/settings/", json={"location_sensor_poll_interval": 30})
+
+        assert response.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_clamps_a_stored_value_below_the_minimum(self, async_client: AsyncClient, db_session):
+        from backend.app.models.settings import Settings
+
+        db_session.add(Settings(key="location_sensor_poll_interval", value="10"))
+        await db_session.commit()
+
+        interval = await location_ha_sensor_manager._get_poll_interval()
+
+        assert interval == 60
+
+
+class TestSaveSurvivesHomeAssistant:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_succeeds_even_if_the_first_read_blows_up(self, async_client: AsyncClient, location_factory):
+        location = await location_factory()
+
+        with patch.object(location_ha_sensor_manager, "refresh_one", AsyncMock(side_effect=RuntimeError("HA said no"))):
+            response = await async_client.post(
+                "/api/v1/location-ha-sensors/", json={**DOOR, "location_id": location.id}
+            )
+
+        assert response.status_code == 200
+        listed = await async_client.get(f"/api/v1/location-ha-sensors/?location_id={location.id}")
+        assert len(listed.json()) == 1

+ 15 - 0
backend/tests/integration/test_locations_api.py

@@ -47,6 +47,21 @@ async def test_locations_crud_and_spool_link(async_client: AsyncClient, db_sessi
     assert delete_resp2.status_code == 200
 
 
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_list_locations_sorts_naturally_not_lexicographically(async_client: AsyncClient):
+    # Created out of order and with a name-shape ("Drybox N") that a plain
+    # ORDER BY name would sort as "Drybox 1", "Drybox 10", "Drybox 2".
+    for name in ["Drybox 10", "Drybox 2", "Drybox 1", "Shelf A"]:
+        resp = await async_client.post("/api/v1/inventory/locations", json={"name": name})
+        assert resp.status_code == 201, resp.text
+
+    list_resp = await async_client.get("/api/v1/inventory/locations")
+    assert list_resp.status_code == 200
+    names = [loc["name"] for loc in list_resp.json()]
+    assert names == ["Drybox 1", "Drybox 2", "Drybox 10", "Shelf A"]
+
+
 @pytest.mark.asyncio
 @pytest.mark.integration
 async def test_rename_location_updates_spool_count(async_client: AsyncClient):

+ 112 - 0
backend/tests/integration/test_notifications_api.py

@@ -5,6 +5,7 @@ Tests the full request/response cycle for /api/v1/notifications/ endpoints.
 
 import pytest
 from httpx import AsyncClient
+from sqlalchemy import text
 
 
 class TestNotificationsAPI:
@@ -38,6 +39,52 @@ class TestNotificationsAPI:
         assert len(data) >= 1
         assert any(p["name"] == "Test Provider" for p in data)
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_row_with_null_event_flags_is_still_listable(
+        self, async_client: AsyncClient, notification_provider_factory, db_session
+    ):
+        """A legacy row whose flag columns were never backfilled must not 500 the list.
+
+        Every on_* column is nullable with no server default, so a row created
+        before a flag existed keeps NULL there until a migration backfills it --
+        and #1184's ALTER ... DEFAULT false silently did not, on any install
+        where create_all() had already added the column. Declaring those flags
+        on the response schema in #2827 turned those NULLs into a hard failure:
+        pydantic rejects None for a bool, so every provider row failed at once
+        and the list came back empty to the UI.
+
+        Written against the two flags that actually broke, but the whole set is
+        checked -- the next flag added to the schema has the same exposure.
+        """
+        provider = await notification_provider_factory(name="Legacy Provider")
+
+        flags = ["on_stock_reorder_alert", "on_stock_break_alert"]
+        await db_session.execute(
+            text(f"UPDATE notification_providers SET {', '.join(f'{f} = NULL' for f in flags)} WHERE id = :id"),
+            {"id": provider.id},
+        )
+        await db_session.commit()
+
+        stored = await db_session.execute(
+            text(f"SELECT {', '.join(flags)} FROM notification_providers WHERE id = :id"), {"id": provider.id}
+        )
+        assert all(value is None for value in stored.one()), "row under test must actually hold NULLs"
+
+        response = await async_client.get("/api/v1/notifications/")
+
+        assert response.status_code == 200
+        listed = next(p for p in response.json() if p["name"] == "Legacy Provider")
+        # Off, not the field default: the sender selects on `.is_(True)`, so a
+        # NULL flag never sent anything, and repairing the read must not switch
+        # a notification on.
+        assert all(listed[flag] is False for flag in flags)
+
+        # The single-provider route reads through the same schema.
+        single = await async_client.get(f"/api/v1/notifications/{provider.id}")
+        assert single.status_code == 200
+        assert all(single.json()[flag] is False for flag in flags)
+
     # ========================================================================
     # Create endpoints
     # ========================================================================
@@ -446,6 +493,71 @@ class TestNotificationsAPI:
         response = await async_client.get(f"/api/v1/notifications/{provider.id}")
         assert response.json()["on_billing_charge_failed"] is False
 
+    # Per-event toggles that live only in these hand-maintained field maps.
+    #
+    # These have to be exercised through the route, not the ORM: both
+    # directions of notifications.py are hand-maintained field-by-field maps,
+    # and a column missing from either one is invisible to any test that
+    # builds NotificationProvider objects directly. The failure mode is
+    # silent — NotificationProviderResponse inherits the field from
+    # NotificationProviderBase, so FastAPI serialises the schema default
+    # (False) instead of raising on the missing key, and the UI reads a
+    # toggle that is on in the database as off.
+    #
+    # The Home Assistant pair (#1148, #2824) was the first to be caught this
+    # way. The stock pair was caught by the same reasoning: its columns, its
+    # templates, its sending code and its whole UI shipped, but the schema
+    # never carried the fields, so Pydantic dropped them from every payload and
+    # the toggles could not be turned on at all.
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    @pytest.mark.parametrize(
+        "field",
+        ["on_ha_sensor_alert", "on_location_ha_sensor_alert", "on_stock_reorder_alert", "on_stock_break_alert"],
+    )
+    async def test_create_persists_and_returns_the_toggle(self, async_client: AsyncClient, field: str):
+        response = await async_client.post(
+            "/api/v1/notifications/",
+            json={
+                "name": "Sensor Alert Test",
+                "provider_type": "ntfy",
+                "config": {"server": "https://ntfy.sh", "topic": "test"},
+                field: True,
+            },
+        )
+
+        assert response.status_code == 200
+        assert response.json()[field] is True
+
+        # Re-read it: a value dropped by the create constructor but echoed
+        # from the request body would still pass the assertion above.
+        provider_id = response.json()["id"]
+        response = await async_client.get(f"/api/v1/notifications/{provider_id}")
+        assert response.json()[field] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    @pytest.mark.parametrize(
+        "field",
+        ["on_ha_sensor_alert", "on_location_ha_sensor_alert", "on_stock_reorder_alert", "on_stock_break_alert"],
+    )
+    async def test_patch_is_reflected_by_every_read_route(
+        self, async_client: AsyncClient, notification_provider_factory, field: str
+    ):
+        """PATCH already persisted (generic setattr loop) — the reads were the broken half."""
+        provider = await notification_provider_factory(**{field: False})
+
+        response = await async_client.patch(f"/api/v1/notifications/{provider.id}", json={field: True})
+        assert response.status_code == 200
+        assert response.json()[field] is True
+
+        response = await async_client.get(f"/api/v1/notifications/{provider.id}")
+        assert response.json()[field] is True
+
+        response = await async_client.get("/api/v1/notifications/")
+        listed = next(p for p in response.json() if p["id"] == provider.id)
+        assert listed[field] is True
+
 
 class TestNotificationTemplatesAPI:
     """Integration tests for /api/v1/notification-templates/ endpoints."""

+ 99 - 0
backend/tests/unit/services/test_location_sensor_alert_provider_scope_2824.py

@@ -0,0 +1,99 @@
+"""Location sensor alerts must not ride on the printer sensor toggle (#2824).
+
+on_location_ha_sensor_alert is its own column, not a reuse of on_ha_sensor_alert.
+That one can be narrowed to a single printer via NotificationProvider.printer_id,
+and a location alert has no printer to narrow by — sharing the column meant a
+provider scoped to one printer's sensors silently also received every drybox
+alert, with no way to have one without the other.
+"""
+
+import pytest
+from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+
+import backend.app.models  # noqa: F401 - populate Base.metadata
+from backend.app.core.database import Base
+from backend.app.models.notification import NotificationProvider
+from backend.app.services.notification_service import NotificationService
+
+
+@pytest.fixture
+async def session(tmp_path):
+    engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'location-sensor-scope.db'}")
+    async with engine.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+    maker = async_sessionmaker(engine, expire_on_commit=False)
+    async with maker() as s:
+        yield s
+    await engine.dispose()
+
+
+@pytest.mark.asyncio
+async def test_printer_scoped_provider_does_not_get_location_alerts_by_default(session):
+    """The regression itself: a provider that only wants one printer's sensor
+    alerts must not also receive storage-location alerts just because it has
+    on_ha_sensor_alert on."""
+    printer_only = NotificationProvider(
+        name="Printer 5 sensors",
+        provider_type="discord",
+        config="{}",
+        enabled=True,
+        printer_id=5,
+        on_ha_sensor_alert=True,
+        on_location_ha_sensor_alert=False,
+    )
+    session.add(printer_only)
+    await session.commit()
+
+    service = NotificationService()
+    providers = await service._get_providers_for_event(session, "on_location_ha_sensor_alert", None)
+
+    assert providers == []
+
+
+@pytest.mark.asyncio
+async def test_provider_opted_into_location_alerts_receives_them(session):
+    global_provider = NotificationProvider(
+        name="Global",
+        provider_type="discord",
+        config="{}",
+        enabled=True,
+        printer_id=None,
+        on_location_ha_sensor_alert=True,
+    )
+    printer_scoped_but_opted_in = NotificationProvider(
+        name="Printer 5, opted into location alerts too",
+        provider_type="discord",
+        config="{}",
+        enabled=True,
+        printer_id=5,
+        on_location_ha_sensor_alert=True,
+    )
+    session.add_all([global_provider, printer_scoped_but_opted_in])
+    await session.commit()
+
+    service = NotificationService()
+    providers = await service._get_providers_for_event(session, "on_location_ha_sensor_alert", None)
+
+    assert {p.name for p in providers} == {"Global", "Printer 5, opted into location alerts too"}
+
+
+@pytest.mark.asyncio
+async def test_printer_sensor_alerts_are_unaffected_by_the_new_column(session):
+    """Printer-scoped sensor alerts still work on their own toggle, independent
+    of whether the provider has ever touched on_location_ha_sensor_alert."""
+    printer_only = NotificationProvider(
+        name="Printer 5 sensors",
+        provider_type="discord",
+        config="{}",
+        enabled=True,
+        printer_id=5,
+        on_ha_sensor_alert=True,
+        on_location_ha_sensor_alert=False,
+    )
+    session.add(printer_only)
+    await session.commit()
+
+    service = NotificationService()
+    providers = await service._get_providers_for_event(session, "on_ha_sensor_alert", 5)
+
+    assert [p.name for p in providers] == ["Printer 5 sensors"]

+ 107 - 0
backend/tests/unit/test_ha_sensor_alert_template_rename_migration_2824.py

@@ -0,0 +1,107 @@
+"""Regression test for the ha_sensor_alert notification template rename migration (#2824).
+
+"Home Assistant Sensor Alert" was fine as a name while it was the only such
+template. Once "Storage Location Sensor Alert" existed alongside it, the
+printer one no longer said which sensor feature it belonged to. The migration
+renames it to "Printer Sensor Alert" IF AND ONLY IF the row still has the old
+default name — an admin who renamed the template themselves keeps their
+custom name, mirroring _migrate_rename_user_print_template_names.
+"""
+
+from __future__ import annotations
+
+import pytest
+from sqlalchemy import text
+from sqlalchemy.ext.asyncio import create_async_engine
+
+from backend.app.core.database import _migrate_rename_ha_sensor_alert_template
+
+
+@pytest.fixture
+async def engine():
+    """In-memory SQLite with just the notification_templates table."""
+    from backend.app.models.notification_template import NotificationTemplate
+
+    engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
+    async with engine.begin() as conn:
+        await conn.run_sync(NotificationTemplate.__table__.create)
+    try:
+        yield engine
+    finally:
+        await engine.dispose()
+
+
+async def _insert_template(conn, event_type: str, name: str) -> None:
+    await conn.execute(
+        text(
+            "INSERT INTO notification_templates "
+            "(event_type, name, title_template, body_template, is_default) "
+            "VALUES (:et, :n, 't', 'b', 1)"
+        ),
+        {"et": event_type, "n": name},
+    )
+
+
+async def _name_for(conn, event_type: str) -> str:
+    return (
+        await conn.execute(
+            text("SELECT name FROM notification_templates WHERE event_type = :et"),
+            {"et": event_type},
+        )
+    ).scalar_one()
+
+
+async def test_renames_the_default_named_row(engine):
+    async with engine.begin() as conn:
+        await _insert_template(conn, "ha_sensor_alert", "Home Assistant Sensor Alert")
+
+    async with engine.begin() as conn:
+        await _migrate_rename_ha_sensor_alert_template(conn)
+
+    async with engine.begin() as conn:
+        assert await _name_for(conn, "ha_sensor_alert") == "Printer Sensor Alert"
+
+
+async def test_preserves_a_user_edited_name(engine):
+    async with engine.begin() as conn:
+        await _insert_template(conn, "ha_sensor_alert", "My Enclosure Alerts")
+
+    async with engine.begin() as conn:
+        await _migrate_rename_ha_sensor_alert_template(conn)
+
+    async with engine.begin() as conn:
+        assert await _name_for(conn, "ha_sensor_alert") == "My Enclosure Alerts"
+
+
+async def test_does_not_touch_the_location_template(engine):
+    async with engine.begin() as conn:
+        await _insert_template(conn, "ha_sensor_alert", "Home Assistant Sensor Alert")
+        await _insert_template(conn, "location_ha_sensor_alert", "Storage Location Sensor Alert")
+
+    async with engine.begin() as conn:
+        await _migrate_rename_ha_sensor_alert_template(conn)
+
+    async with engine.begin() as conn:
+        assert await _name_for(conn, "location_ha_sensor_alert") == "Storage Location Sensor Alert"
+
+
+async def test_is_idempotent(engine):
+    async with engine.begin() as conn:
+        await _insert_template(conn, "ha_sensor_alert", "Home Assistant Sensor Alert")
+
+    async with engine.begin() as conn:
+        await _migrate_rename_ha_sensor_alert_template(conn)
+    async with engine.begin() as conn:
+        await _migrate_rename_ha_sensor_alert_template(conn)
+
+    async with engine.begin() as conn:
+        assert await _name_for(conn, "ha_sensor_alert") == "Printer Sensor Alert"
+
+
+async def test_handles_empty_table(engine):
+    async with engine.begin() as conn:
+        await _migrate_rename_ha_sensor_alert_template(conn)
+
+    async with engine.begin() as conn:
+        count = (await conn.execute(text("SELECT COUNT(*) FROM notification_templates"))).scalar_one()
+        assert count == 0

+ 62 - 0
backend/tests/unit/test_ha_sensor_manager_1148.py

@@ -279,3 +279,65 @@ class TestNotificationEdge:
         await self._apply(manager, sensor, {sensor.entity_id: {"state": "on"}}, notify)
 
         assert notify.on_ha_sensor_alert.await_count == 1
+
+
+class TestLastStatePersistence:
+    """What goes into last_state must fit its String(64) column.
+
+    A numeric entity can start reporting free text — an enum, an error string
+    from a template sensor — longer than the column. SQLite stores it anyway,
+    but PostgreSQL rejects the row, and _apply commits the whole pass at once:
+    one such entity would sink every printer sensor's update on every tick,
+    which also freezes what the print interlock is looking at.
+
+    The same rule as the storage-location poller, through the same helper.
+    """
+
+    async def _apply(self, manager, sensor, state):
+        db = AsyncMock()
+        with patch("backend.app.services.notification_service.notification_service", AsyncMock()):
+            await manager._apply(db, [sensor], {sensor.entity_id: {"state": state}})
+
+    @pytest.mark.asyncio
+    async def test_a_long_text_state_is_cut_to_the_column_width(self):
+        manager = HASensorManager()
+        sensor = _numeric(last_changed=None, last_checked=None)
+        long_state = "x" * 500
+
+        await self._apply(manager, sensor, long_state)
+
+        assert sensor.last_state == "x" * 64
+        # The cache keeps the full state — only what is persisted is cut.
+        assert manager.get_reading(sensor.id).state == long_state
+
+    @pytest.mark.asyncio
+    async def test_an_unchanged_long_state_is_not_a_change_on_every_poll(self):
+        manager = HASensorManager()
+        sensor = _numeric(last_changed=None, last_checked=None)
+        long_state = "x" * 500
+
+        await self._apply(manager, sensor, long_state)
+        first_changed = sensor.last_changed
+        await self._apply(manager, sensor, long_state)
+
+        # Comparing the stored (cut) value against the raw state would read as
+        # a difference on every poll and churn last_changed forever.
+        assert sensor.last_changed == first_changed
+
+    @pytest.mark.asyncio
+    async def test_refresh_one_cuts_it_too(self):
+        """The single-sensor path a create or an edit takes, not just the loop."""
+        manager = HASensorManager()
+        sensor = _numeric(last_changed=None, last_checked=None)
+        db = AsyncMock()
+
+        with (
+            patch.object(manager, "_configure", AsyncMock(return_value=True)),
+            patch(
+                "backend.app.services.ha_sensor_manager.homeassistant_service.fetch_states",
+                AsyncMock(return_value={sensor.entity_id: {"state": "y" * 300}}),
+            ),
+        ):
+            await manager.refresh_one(db, sensor)
+
+        assert sensor.last_state == "y" * 64

+ 183 - 0
backend/tests/unit/test_location_ha_sensor_manager_2824.py

@@ -0,0 +1,183 @@
+import asyncio
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from backend.app.services.location_ha_sensor_manager import LocationHASensorManager
+
+
+def _sensor(**overrides):
+    base = {
+        "id": 1,
+        "location_id": 7,
+        "name": "Drybox Humidity",
+        "entity_id": "sensor.drybox_humidity",
+        "kind": "numeric",
+        "device_class": "humidity",
+        "unit": "%",
+        "alert_state": None,
+        "alert_above": 60,
+        "alert_below": None,
+        "notify_on_alert": False,
+        "last_state": None,
+    }
+    base.update(overrides)
+    return SimpleNamespace(**base)
+
+
+class TestNotificationEdge:
+    async def _apply(self, manager, sensor, states, notify):
+        db = AsyncMock()
+        db.get.return_value = SimpleNamespace(name="Drybox 1")
+        with patch("backend.app.services.notification_service.notification_service", notify):
+            await manager._apply(db, [sensor], states)
+
+    async def test_fires_once_on_the_way_in(self):
+        manager = LocationHASensorManager()
+        sensor = _sensor(notify_on_alert=True)
+        notify = AsyncMock()
+
+        await self._apply(manager, sensor, {sensor.entity_id: {"state": "40"}}, notify)
+        assert notify.on_location_ha_sensor_alert.await_count == 0
+
+        await self._apply(manager, sensor, {sensor.entity_id: {"state": "65"}}, notify)
+        assert notify.on_location_ha_sensor_alert.await_count == 1
+
+        await self._apply(manager, sensor, {sensor.entity_id: {"state": "66"}}, notify)
+        assert notify.on_location_ha_sensor_alert.await_count == 1
+
+    async def test_silent_on_the_first_poll_after_a_restart(self):
+        manager = LocationHASensorManager()
+        sensor = _sensor(notify_on_alert=True)
+        notify = AsyncMock()
+
+        await self._apply(manager, sensor, {sensor.entity_id: {"state": "65"}}, notify)
+
+        assert notify.on_location_ha_sensor_alert.await_count == 0
+        assert manager.get_reading(sensor.id).alerting is True
+
+    async def test_silent_when_the_sensor_opts_out(self):
+        manager = LocationHASensorManager()
+        sensor = _sensor(notify_on_alert=False)
+        notify = AsyncMock()
+
+        await self._apply(manager, sensor, {sensor.entity_id: {"state": "40"}}, notify)
+        await self._apply(manager, sensor, {sensor.entity_id: {"state": "65"}}, notify)
+
+        assert notify.on_location_ha_sensor_alert.await_count == 0
+
+    async def test_passes_the_location_name_not_a_printer_name(self):
+        manager = LocationHASensorManager()
+        sensor = _sensor(notify_on_alert=True)
+        notify = AsyncMock()
+
+        await self._apply(manager, sensor, {sensor.entity_id: {"state": "40"}}, notify)
+        await self._apply(manager, sensor, {sensor.entity_id: {"state": "65"}}, notify)
+
+        notify.on_location_ha_sensor_alert.assert_awaited_once()
+        _, kwargs = notify.on_location_ha_sensor_alert.await_args
+        assert kwargs["location_name"] == "Drybox 1"
+        assert kwargs["sensor_name"] == "Drybox Humidity"
+
+    async def test_a_dropout_does_not_count_as_the_alert_clearing(self):
+        manager = LocationHASensorManager()
+        sensor = _sensor(notify_on_alert=True)
+        notify = AsyncMock()
+
+        await self._apply(manager, sensor, {sensor.entity_id: {"state": "40"}}, notify)
+        await self._apply(manager, sensor, {sensor.entity_id: {"state": "65"}}, notify)
+        await self._apply(manager, sensor, {sensor.entity_id: None}, notify)
+        await self._apply(manager, sensor, {sensor.entity_id: {"state": "66"}}, notify)
+
+        assert notify.on_location_ha_sensor_alert.await_count == 1
+
+
+class TestLastStatePersistence:
+    """What goes into last_state must fit its String(64) column.
+
+    A numeric entity can start reporting free text longer than the column.
+    SQLite stores it anyway, but PostgreSQL rejects the row — and since
+    _apply commits the whole pass at once, one such sensor would sink every
+    sensor's update on every tick.
+    """
+
+    async def _apply(self, manager, sensor, state):
+        db = AsyncMock()
+        with patch("backend.app.services.notification_service.notification_service", AsyncMock()):
+            await manager._apply(db, [sensor], {sensor.entity_id: {"state": state}})
+
+    async def test_a_long_text_state_is_cut_to_the_column_width(self):
+        manager = LocationHASensorManager()
+        sensor = _sensor(last_changed=None, last_checked=None)
+        long_state = "x" * 500
+
+        await self._apply(manager, sensor, long_state)
+
+        assert sensor.last_state == "x" * 64
+        # The cache keeps the full state — only what is persisted is cut.
+        assert manager.get_reading(sensor.id).state == long_state
+
+    async def test_an_unchanged_long_state_is_not_a_change_on_every_poll(self):
+        manager = LocationHASensorManager()
+        sensor = _sensor(last_changed=None, last_checked=None)
+        long_state = "x" * 500
+
+        await self._apply(manager, sensor, long_state)
+        first_changed = sensor.last_changed
+        await self._apply(manager, sensor, long_state)
+
+        # Comparing the stored (cut) value against the raw state would see a
+        # difference on every poll and churn last_changed forever.
+        assert sensor.last_changed == first_changed
+
+
+class TestPollLoopSurvival:
+    """The loop must outlive a transient database error (#2824 review).
+
+    _get_poll_interval() reads Settings, so the sleep leg of _poll_loop does
+    I/O and can raise on pool exhaustion or a restarting database. Letting
+    that escape ends the task permanently: stop() is what clears _task, so a
+    self-terminated loop leaves it set and start() refuses to restart it.
+    """
+
+    async def test_survives_a_failing_poll_interval_lookup(self):
+        manager = LocationHASensorManager()
+        polls = 0
+
+        async def counting_poll():
+            nonlocal polls
+            polls += 1
+            if polls >= 2:
+                raise asyncio.CancelledError  # end the loop once we've proven it came back
+
+        async def failing_interval():
+            raise RuntimeError("QueuePool limit reached")
+
+        with (
+            patch.object(manager, "poll_once", counting_poll),
+            patch.object(manager, "_get_poll_interval", failing_interval),
+            patch("backend.app.services.location_ha_sensor_manager.POLL_INTERVAL", 0),
+        ):
+            await manager._poll_loop()
+
+        # Without the guard the first lookup failure escapes _poll_loop and
+        # poll_once never runs a second time.
+        assert polls == 2
+
+    async def test_a_cancel_during_the_fallback_sleep_still_stops_the_loop(self):
+        manager = LocationHASensorManager()
+
+        async def failing_interval():
+            raise RuntimeError("database is locked")
+
+        with (
+            patch.object(manager, "poll_once", AsyncMock()),
+            patch.object(manager, "_get_poll_interval", failing_interval),
+            patch("backend.app.services.location_ha_sensor_manager.POLL_INTERVAL", 3600),
+        ):
+            task = asyncio.create_task(manager._poll_loop())
+            await asyncio.sleep(0)  # let it reach the fallback sleep
+            task.cancel()
+            with pytest.raises(asyncio.CancelledError):
+                await task

+ 108 - 0
backend/tests/unit/test_location_ha_sensor_unique_binding_migration_2824.py

@@ -0,0 +1,108 @@
+"""Regression tests for the location_ha_sensors unique-binding migration (#2824).
+
+The API's duplicate check is read-then-insert, so two concurrent creates could
+both pass it before the unique index existed. The migration adds the index to
+databases whose table predates it (create_all only covers fresh installs) —
+and first collapses any duplicates the pre-index race let in, keeping the
+oldest row, because CREATE UNIQUE INDEX refuses to build over duplicates and
+that failure would abort startup.
+"""
+
+from __future__ import annotations
+
+import pytest
+from sqlalchemy import text
+from sqlalchemy.exc import IntegrityError
+from sqlalchemy.ext.asyncio import create_async_engine
+
+from backend.app.core.database import _migrate_location_ha_sensor_unique_binding
+
+
+@pytest.fixture
+async def engine():
+    """In-memory SQLite with the table as it existed BEFORE the index."""
+    engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
+    async with engine.begin() as conn:
+        await conn.execute(
+            text(
+                "CREATE TABLE location_ha_sensors ("
+                "id INTEGER PRIMARY KEY AUTOINCREMENT, "
+                "location_id INTEGER NOT NULL, "
+                "name VARCHAR(100) NOT NULL, "
+                "entity_id VARCHAR(255) NOT NULL)"
+            )
+        )
+    try:
+        yield engine
+    finally:
+        await engine.dispose()
+
+
+async def _insert(conn, location_id: int, entity_id: str, name: str = "Sensor") -> None:
+    await conn.execute(
+        text("INSERT INTO location_ha_sensors (location_id, name, entity_id) VALUES (:l, :n, :e)"),
+        {"l": location_id, "n": name, "e": entity_id},
+    )
+
+
+async def _rows(conn) -> list[tuple]:
+    result = await conn.execute(text("SELECT id, location_id, entity_id FROM location_ha_sensors ORDER BY id"))
+    return list(result.all())
+
+
+async def test_collapses_duplicates_to_the_oldest_row(engine):
+    async with engine.begin() as conn:
+        await _insert(conn, 1, "sensor.humidity", "Original")
+        await _insert(conn, 1, "sensor.humidity", "Race duplicate")
+        await _insert(conn, 1, "sensor.temperature")
+
+    async with engine.begin() as conn:
+        await _migrate_location_ha_sensor_unique_binding(conn)
+
+    async with engine.begin() as conn:
+        assert await _rows(conn) == [(1, 1, "sensor.humidity"), (3, 1, "sensor.temperature")]
+
+
+async def test_the_same_entity_on_two_locations_is_not_a_duplicate(engine):
+    async with engine.begin() as conn:
+        await _insert(conn, 1, "sensor.humidity")
+        await _insert(conn, 2, "sensor.humidity")
+
+    async with engine.begin() as conn:
+        await _migrate_location_ha_sensor_unique_binding(conn)
+
+    async with engine.begin() as conn:
+        assert len(await _rows(conn)) == 2
+
+
+async def test_the_index_then_rejects_new_duplicates(engine):
+    async with engine.begin() as conn:
+        await _migrate_location_ha_sensor_unique_binding(conn)
+
+    async with engine.begin() as conn:
+        await _insert(conn, 1, "sensor.humidity")
+
+    with pytest.raises(IntegrityError):
+        async with engine.begin() as conn:
+            await _insert(conn, 1, "sensor.humidity")
+
+
+async def test_is_idempotent(engine):
+    async with engine.begin() as conn:
+        await _insert(conn, 1, "sensor.humidity")
+
+    async with engine.begin() as conn:
+        await _migrate_location_ha_sensor_unique_binding(conn)
+    async with engine.begin() as conn:
+        await _migrate_location_ha_sensor_unique_binding(conn)
+
+    async with engine.begin() as conn:
+        assert len(await _rows(conn)) == 1
+
+
+async def test_handles_empty_table(engine):
+    async with engine.begin() as conn:
+        await _migrate_location_ha_sensor_unique_binding(conn)
+
+    async with engine.begin() as conn:
+        assert await _rows(conn) == []

+ 42 - 0
backend/tests/unit/test_natural_sort.py

@@ -0,0 +1,42 @@
+"""Storage locations sort naturally, not lexicographically (issue: "Drybox 2"
+belongs before "Drybox 10")."""
+
+from backend.app.utils.natural_sort import natural_sort_key
+
+
+def test_orders_embedded_numbers_by_value_not_by_character():
+    names = ["Drybox 10", "Drybox 2", "Drybox 1"]
+    assert sorted(names, key=natural_sort_key) == ["Drybox 1", "Drybox 2", "Drybox 10"]
+
+
+def test_matches_plain_alphabetical_order_when_there_are_no_digits():
+    names = ["Shelf B", "Shelf A", "Shelf C"]
+    assert sorted(names, key=natural_sort_key) == ["Shelf A", "Shelf B", "Shelf C"]
+
+
+def test_is_case_insensitive():
+    names = ["shelf", "Drybox", "SHELF A"]
+    assert sorted(names, key=natural_sort_key) == ["Drybox", "shelf", "SHELF A"]
+
+
+def test_names_with_no_digits_sort_before_the_same_prefix_with_a_number():
+    # "Drybox" (len-1 key) is a prefix of "Drybox 1" (len-3 key); Python
+    # tuple comparison puts the shorter, exhausted tuple first.
+    names = ["Drybox 1", "Drybox"]
+    assert sorted(names, key=natural_sort_key) == ["Drybox", "Drybox 1"]
+
+
+def test_handles_multiple_number_runs_in_one_name():
+    names = ["Row 10 Bin 2", "Row 2 Bin 10", "Row 2 Bin 2"]
+    assert sorted(names, key=natural_sort_key) == ["Row 2 Bin 2", "Row 2 Bin 10", "Row 10 Bin 2"]
+
+
+def test_does_not_raise_when_a_str_and_int_position_would_otherwise_collide():
+    # A regression guard for the tuple-comparison hazard described in the
+    # module docstring: mixing names where a digit run appears at different
+    # positions must not raise "'<' not supported between instances of 'int'
+    # and 'str'" — every key's even indices are always str and odd indices
+    # are always int, so this must simply sort without error.
+    names = ["A1", "1A", "AA", "11"]
+    result = sorted(names, key=natural_sort_key)
+    assert set(result) == set(names)

+ 26 - 0
backend/tests/unit/test_notification_template_sample_data.py

@@ -0,0 +1,26 @@
+"""Every event_type in EVENT_VARIABLES needs a matching SAMPLE_DATA entry.
+
+Regression: location_ha_sensor_alert (#2824) was added to EVENT_VARIABLES but
+never given a SAMPLE_DATA entry. The template preview endpoint falls back to
+an empty sample dict for an event type it has no data for, so every
+{placeholder} in the template silently disappears — "{location}: {sensor} is
+{state}" rendered as just ": is " with nothing to say what did what.
+"""
+
+from backend.app.schemas.notification_template import EVENT_VARIABLES, SAMPLE_DATA
+
+
+def test_every_event_type_has_sample_data():
+    missing = sorted(set(EVENT_VARIABLES) - set(SAMPLE_DATA))
+    assert missing == [], f"EVENT_VARIABLES entries with no SAMPLE_DATA: {missing}"
+
+
+def test_sample_data_covers_every_variable_its_event_type_declares():
+    # A partial sample (declared variable missing a sample value) produces
+    # the same silent-blank symptom as a missing sample entirely.
+    incomplete = {
+        event_type: sorted(set(variables) - set(SAMPLE_DATA.get(event_type, {})))
+        for event_type, variables in EVENT_VARIABLES.items()
+    }
+    incomplete = {k: v for k, v in incomplete.items() if v}
+    assert incomplete == {}, f"Event types with variables missing from their sample data: {incomplete}"

+ 52 - 0
frontend/src/__tests__/components/AddNotificationModal.test.tsx

@@ -458,6 +458,58 @@ describe('AddNotificationModal — AI Failure Detection toggle (#1794)', () => {
   });
 });
 
+describe('AddNotificationModal — Storage Location Sensor Alert toggle (#2824)', () => {
+  it('renders its own toggle, separate from the printer Sensor Alert toggle', async () => {
+    render(<AddNotificationModal provider={buildProvider()} onClose={() => undefined} />);
+
+    expect(await screen.findByText('Printer Sensor Alert')).toBeInTheDocument();
+    expect(await screen.findByText('Storage Location Sensor Alert')).toBeInTheDocument();
+  });
+
+  it('persists on_location_ha_sensor_alert on save (and does NOT touch on_ha_sensor_alert)', async () => {
+    // The regression this guards: the two events used to share one column,
+    // so a provider scoped to a single printer's sensor alerts would also
+    // start receiving storage-location alerts the moment either toggle went on.
+    let captured: Record<string, unknown> | null = null;
+    server.use(
+      http.patch('*/api/v1/notifications/1', async ({ request }) => {
+        captured = (await request.json()) as Record<string, unknown>;
+        return HttpResponse.json({ id: 1 });
+      }),
+    );
+
+    const onClose = vi.fn();
+    const user = userEvent.setup();
+    render(<AddNotificationModal provider={buildProvider()} onClose={onClose} />);
+
+    const label = await screen.findByText('Storage Location Sensor Alert');
+    const row = label.closest('div.flex')!;
+    const toggle = within(row).getByRole('switch');
+    await user.click(toggle);
+
+    await user.click(screen.getByRole('button', { name: /^save$/i }));
+    await waitFor(() => expect(onClose).toHaveBeenCalled());
+
+    expect(captured).not.toBeNull();
+    expect(captured!.on_location_ha_sensor_alert).toBe(true);
+    expect(captured!.on_ha_sensor_alert).toBe(false);
+  });
+
+  it('appears in ntfy priority section when enabled', async () => {
+    render(
+      <AddNotificationModal
+        provider={buildProvider({ provider_type: 'ntfy', on_location_ha_sensor_alert: true })}
+        onClose={() => undefined}
+      />,
+    );
+
+    const priorityHeader = await screen.findByText(/ntfy priority/i);
+    const priorityRoot = priorityHeader.closest('div')!;
+
+    expect(within(priorityRoot).getByText('Storage Location Sensor Alert')).toBeInTheDocument();
+  });
+});
+
 describe('AddNotificationModal — Home Assistant custom data (#1441)', () => {
   const haProvider = () =>
     buildProvider({

+ 731 - 0
frontend/src/__tests__/components/LocationHASensorModal.test.tsx

@@ -0,0 +1,731 @@
+import { screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { LocationHASensorModal } from '../../components/LocationHASensorModal';
+import { api } from '../../api/client';
+import { render } from '../utils';
+
+vi.mock('../../api/client', async () => {
+  const actual = await vi.importActual<typeof import('../../api/client')>('../../api/client');
+  return {
+    ...actual,
+    api: {
+      ...actual.api,
+      getSettings: vi.fn(),
+      getBindableLocationHAEntities: vi.fn(),
+      getLocationHASensors: vi.fn(),
+      createLocationHASensor: vi.fn(),
+      updateLocationHASensor: vi.fn(),
+    },
+  };
+});
+
+const getSettings = vi.mocked(api.getSettings);
+const getEntities = vi.mocked(api.getBindableLocationHAEntities);
+const getLocationSensors = vi.mocked(api.getLocationHASensors);
+const createSensor = vi.mocked(api.createLocationHASensor);
+const updateSensor = vi.mocked(api.updateLocationHASensor);
+
+const LOCATIONS = [{ id: 7, name: 'Drybox 1' }] as never;
+
+function settings(overrides = {}) {
+  return {
+    ha_enabled: true,
+    ha_url: 'http://homeassistant.local:8123',
+    ha_token: 'token',
+    ...overrides,
+  } as never;
+}
+
+describe('LocationHASensorModal', () => {
+  beforeEach(() => {
+    getSettings.mockReset();
+    getEntities.mockReset();
+    getEntities.mockResolvedValue([]);
+    getLocationSensors.mockReset();
+    getLocationSensors.mockResolvedValue([]);
+    createSensor.mockReset();
+    createSensor.mockResolvedValue({} as never);
+    updateSensor.mockReset();
+    updateSensor.mockResolvedValue({} as never);
+    vi.mocked(window.localStorage.getItem).mockReset();
+  });
+
+  it('warns when Home Assistant is not configured at all', async () => {
+    getSettings.mockResolvedValue(settings({ ha_enabled: false, ha_url: '', ha_token: '' }));
+
+    render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
+
+    expect(
+      await screen.findByText(/Home Assistant is not configured/)
+    ).toBeInTheDocument();
+    expect(screen.getByText('Settings → Network → Home Assistant')).toBeInTheDocument();
+  });
+
+  it('warns when the integration is configured but switched off', async () => {
+    getSettings.mockResolvedValue(settings({ ha_enabled: false }));
+
+    render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
+
+    expect(await screen.findByText(/Home Assistant is not configured/)).toBeInTheDocument();
+  });
+
+  it('does not ask Home Assistant for entities it cannot reach', async () => {
+    getSettings.mockResolvedValue(settings({ ha_token: '' }));
+
+    render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
+
+    await screen.findByText(/Home Assistant is not configured/);
+    expect(getEntities).not.toHaveBeenCalled();
+  });
+
+  it('blocks saving a new sensor while unconfigured', async () => {
+    getSettings.mockResolvedValue(settings({ ha_enabled: false }));
+
+    render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
+
+    await screen.findByText(/Home Assistant is not configured/);
+    expect(screen.getByRole('button', { name: /save/i })).toBeDisabled();
+  });
+
+  it('shows no warning once Home Assistant is configured', async () => {
+    getSettings.mockResolvedValue(settings());
+
+    render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
+
+    await waitFor(() => expect(getEntities).toHaveBeenCalled());
+    expect(screen.queryByText(/Home Assistant is not configured/)).not.toBeInTheDocument();
+  });
+
+  it('only offers temperature, humidity, and battery entities, not other device classes', async () => {
+    getSettings.mockResolvedValue(settings());
+    getEntities.mockResolvedValue([
+      { entity_id: 'sensor.drybox_1_temp', friendly_name: 'Drybox 1 Temp', domain: 'sensor', device_class: 'temperature', unit_of_measurement: '°C', state: '21.0' },
+      { entity_id: 'sensor.drybox_1_humidity', friendly_name: 'Drybox 1 Humidity', domain: 'sensor', device_class: 'humidity', unit_of_measurement: '%', state: '40' },
+      { entity_id: 'sensor.drybox_1_battery', friendly_name: 'Drybox 1 Battery', domain: 'sensor', device_class: 'battery', unit_of_measurement: '%', state: '90' },
+      { entity_id: 'binary_sensor.drybox_1_door', friendly_name: 'Drybox 1 Door', domain: 'binary_sensor', device_class: 'door', unit_of_measurement: null, state: 'off' },
+    ] as never);
+
+    render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
+
+    expect(await screen.findByText('Drybox 1 Temp')).toBeInTheDocument();
+    expect(screen.getByText('Drybox 1 Humidity')).toBeInTheDocument();
+    expect(screen.getByText('Drybox 1 Battery')).toBeInTheDocument();
+    expect(screen.queryByText('Drybox 1 Door')).not.toBeInTheDocument();
+  });
+
+  it('only offers a "below" alert threshold for battery sensors, not "above"', async () => {
+    getSettings.mockResolvedValue(settings());
+    getEntities.mockResolvedValue([
+      { entity_id: 'sensor.drybox_1_battery', friendly_name: 'Drybox 1 Battery', domain: 'sensor', device_class: 'battery', unit_of_measurement: '%', state: '90' },
+    ] as never);
+    getLocationSensors.mockResolvedValue([]);
+
+    const user = userEvent.setup();
+    render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
+
+    await user.click(await screen.findByText('Drybox 1 Battery'));
+
+    expect(screen.queryByText(/^Above/)).not.toBeInTheDocument();
+    expect(screen.getByText(/^Below/)).toBeInTheDocument();
+    expect(screen.getAllByRole('spinbutton')).toHaveLength(1);
+  });
+
+  it('prefills the name field from the entity\'s friendly name, not the raw entity id', async () => {
+    getSettings.mockResolvedValue(settings());
+    getEntities.mockResolvedValue([
+      { entity_id: 'sensor.drybox_1_temp', friendly_name: 'Drybox 1 Temp', domain: 'sensor', device_class: 'temperature', unit_of_measurement: '°C', state: '21.0' },
+    ] as never);
+
+    const user = userEvent.setup();
+    render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
+
+    await user.click(await screen.findByText('Drybox 1 Temp'));
+
+    expect(screen.getByLabelText(/name/i)).toHaveValue('Drybox 1 Temp');
+  });
+
+  it('follows the name field to the newly picked entity, as long as it was not typed by hand', async () => {
+    getSettings.mockResolvedValue(settings());
+    getEntities.mockResolvedValue([
+      { entity_id: 'sensor.drybox_1_temp', friendly_name: 'Drybox 1 Temp', domain: 'sensor', device_class: 'temperature', unit_of_measurement: '°C', state: '21.0' },
+      { entity_id: 'sensor.drybox_2_temp', friendly_name: 'Drybox 2 Temp', domain: 'sensor', device_class: 'temperature', unit_of_measurement: '°C', state: '19.0' },
+    ] as never);
+
+    const user = userEvent.setup();
+    render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
+
+    await user.click(await screen.findByText('Drybox 1 Temp'));
+    expect(screen.getByLabelText(/name/i)).toHaveValue('Drybox 1 Temp');
+
+    await user.click(screen.getByText('Drybox 2 Temp'));
+    expect(screen.getByLabelText(/name/i)).toHaveValue('Drybox 2 Temp');
+  });
+
+  it('does not overwrite a hand-typed name when a different entity is picked', async () => {
+    getSettings.mockResolvedValue(settings());
+    getEntities.mockResolvedValue([
+      { entity_id: 'sensor.drybox_1_temp', friendly_name: 'Drybox 1 Temp', domain: 'sensor', device_class: 'temperature', unit_of_measurement: '°C', state: '21.0' },
+      { entity_id: 'sensor.drybox_2_temp', friendly_name: 'Drybox 2 Temp', domain: 'sensor', device_class: 'temperature', unit_of_measurement: '°C', state: '19.0' },
+    ] as never);
+
+    const user = userEvent.setup();
+    render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
+
+    await user.click(await screen.findByText('Drybox 1 Temp'));
+    const nameInput = screen.getByLabelText(/name/i);
+    await user.clear(nameInput);
+    await user.type(nameInput, 'My Custom Name');
+
+    await user.click(screen.getByText('Drybox 2 Temp'));
+    expect(screen.getByLabelText(/name/i)).toHaveValue('My Custom Name');
+  });
+
+  it('saves the friendly name, not the raw entity id', async () => {
+    getSettings.mockResolvedValue(settings());
+    getEntities.mockResolvedValue([
+      { entity_id: 'sensor.drybox_1_temp', friendly_name: 'Drybox 1 Temp', domain: 'sensor', device_class: 'temperature', unit_of_measurement: '°C', state: '21.0' },
+    ] as never);
+    getLocationSensors.mockResolvedValue([]);
+
+    const user = userEvent.setup();
+    render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
+
+    await user.click(await screen.findByText('Drybox 1 Temp'));
+    await user.click(screen.getByRole('button', { name: /save/i }));
+
+    await waitFor(() => expect(createSensor).toHaveBeenCalledTimes(1));
+    expect(createSensor).toHaveBeenCalledWith(expect.objectContaining({ name: 'Drybox 1 Temp' }));
+  });
+
+  it('slices an oversized unit to the column width, like the name', async () => {
+    // The unit is snapshotted from Home Assistant, not typed by the user —
+    // an entity reporting a unit longer than the String(16) column must not
+    // come back as a 422 on a field the form never showed.
+    getSettings.mockResolvedValue(settings());
+    getEntities.mockResolvedValue([
+      { entity_id: 'sensor.drybox_1_temp', friendly_name: 'Drybox 1 Temp', domain: 'sensor', device_class: 'temperature', unit_of_measurement: 'degrees Celsius (integrated)', state: '21.0' },
+    ] as never);
+    getLocationSensors.mockResolvedValue([]);
+
+    const user = userEvent.setup();
+    render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
+
+    await user.click(await screen.findByText('Drybox 1 Temp'));
+    await user.click(screen.getByRole('button', { name: /save/i }));
+
+    await waitFor(() => expect(createSensor).toHaveBeenCalledTimes(1));
+    expect(createSensor).toHaveBeenCalledWith(
+      expect.objectContaining({ unit: 'degrees Celsius (integrated)'.slice(0, 16) })
+    );
+  });
+
+  it('requires a name before saving', async () => {
+    getSettings.mockResolvedValue(settings());
+    getEntities.mockResolvedValue([
+      { entity_id: 'sensor.drybox_1_temp', friendly_name: 'Drybox 1 Temp', domain: 'sensor', device_class: 'temperature', unit_of_measurement: '°C', state: '21.0' },
+    ] as never);
+
+    const user = userEvent.setup();
+    render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
+
+    await user.click(await screen.findByText('Drybox 1 Temp'));
+    await user.clear(screen.getByLabelText(/name/i));
+    await user.click(screen.getByRole('button', { name: /save/i }));
+
+    expect(await screen.findByText('Enter a display name')).toBeInTheDocument();
+    expect(createSensor).not.toHaveBeenCalled();
+  });
+
+  it('clears a stale "above" value when saving an existing battery sensor', async () => {
+    getSettings.mockResolvedValue(settings());
+    getEntities.mockResolvedValue([]);
+    getLocationSensors.mockResolvedValue([]);
+
+    const user = userEvent.setup();
+    render(
+      <LocationHASensorModal
+        sensor={
+          {
+            id: 1,
+            location_id: 7,
+            name: 'sensor.drybox_1_battery',
+            entity_id: 'sensor.drybox_1_battery',
+            kind: 'numeric',
+            device_class: 'battery',
+            unit: '%',
+            alert_state: null,
+            alert_above: 95,
+            alert_below: 15,
+            notify_on_alert: false,
+            show_on_card: true,
+            sort_order: 0,
+            last_state: null,
+            last_changed: null,
+            last_checked: null,
+            created_at: '',
+            updated_at: '',
+          } as never
+        }
+        locations={LOCATIONS}
+        onClose={() => {}}
+      />
+    );
+
+    await user.click(await screen.findByRole('button', { name: /save/i }));
+
+    await waitFor(() => expect(updateSensor).toHaveBeenCalledTimes(1));
+    expect(updateSensor).toHaveBeenCalledWith(1, expect.objectContaining({ alert_above: null, alert_below: 15 }));
+  });
+
+  it('shows the location field only when creating a new sensor', async () => {
+    getSettings.mockResolvedValue(settings());
+
+    render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
+
+    expect(await screen.findByText('Drybox 1')).toBeInTheDocument();
+  });
+
+  it('asks to replace the existing sensor when adding a second temperature sensor', async () => {
+    getSettings.mockResolvedValue(settings());
+    getEntities.mockResolvedValue([
+      {
+        entity_id: 'sensor.drybox_1_temp_2',
+        friendly_name: 'Drybox 1 Temp 2',
+        domain: 'sensor',
+        device_class: 'temperature',
+        unit_of_measurement: '°C',
+        state: '21.0',
+      },
+    ] as never);
+    getLocationSensors.mockResolvedValue([
+      {
+        id: 1,
+        location_id: 7,
+        name: 'sensor.drybox_1_temp',
+        entity_id: 'sensor.drybox_1_temp',
+        kind: 'numeric',
+        device_class: 'temperature',
+        unit: '°C',
+        alert_state: null,
+        alert_above: null,
+        alert_below: null,
+        notify_on_alert: false,
+        show_on_card: true,
+        sort_order: 0,
+        last_state: null,
+        last_changed: null,
+        last_checked: null,
+        created_at: '',
+        updated_at: '',
+      },
+    ] as never);
+
+    const user = userEvent.setup();
+    render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
+
+    await user.click(await screen.findByText('Drybox 1 Temp 2'));
+    await user.click(screen.getByRole('button', { name: /save/i }));
+
+    expect(await screen.findByText('Replace existing sensor?')).toBeInTheDocument();
+    expect(
+      screen.getByText(/already has a temperature sensor bound: sensor\.drybox_1_temp\./)
+    ).toBeInTheDocument();
+  });
+
+  it('asks to replace the existing sensor when adding a second humidity sensor', async () => {
+    getSettings.mockResolvedValue(settings());
+    getEntities.mockResolvedValue([
+      {
+        entity_id: 'sensor.drybox_1_humidity_2',
+        friendly_name: 'Drybox 1 Humidity 2',
+        domain: 'sensor',
+        device_class: 'humidity',
+        unit_of_measurement: '%',
+        state: '45',
+      },
+    ] as never);
+    getLocationSensors.mockResolvedValue([
+      {
+        id: 2,
+        location_id: 7,
+        name: 'sensor.drybox_1_humidity',
+        entity_id: 'sensor.drybox_1_humidity',
+        kind: 'numeric',
+        device_class: 'humidity',
+        unit: '%',
+        alert_state: null,
+        alert_above: null,
+        alert_below: null,
+        notify_on_alert: false,
+        show_on_card: true,
+        sort_order: 0,
+        last_state: null,
+        last_changed: null,
+        last_checked: null,
+        created_at: '',
+        updated_at: '',
+      },
+    ] as never);
+
+    const user = userEvent.setup();
+    render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
+
+    await user.click(await screen.findByText('Drybox 1 Humidity 2'));
+    await user.click(screen.getByRole('button', { name: /save/i }));
+
+    expect(await screen.findByText('Replace existing sensor?')).toBeInTheDocument();
+    expect(
+      screen.getByText(/already has a humidity sensor bound: sensor\.drybox_1_humidity\./)
+    ).toBeInTheDocument();
+  });
+
+  it('offers to bind sibling entities when adding the first sensor for a location', async () => {
+    getSettings.mockResolvedValue(settings());
+    getEntities.mockResolvedValue([
+      { entity_id: 'sensor.drybox_1_humidity', friendly_name: 'Drybox 1 Humidity', domain: 'sensor', device_class: 'humidity', unit_of_measurement: '%', state: '40' },
+      // Oversized unit: the auto-add path must slice it to the String(16)
+      // column just like the primary save does.
+      { entity_id: 'sensor.drybox_1_temperature', friendly_name: 'Drybox 1 Temperature', domain: 'sensor', device_class: 'temperature', unit_of_measurement: 'degrees Celsius (integrated)', state: '21.0' },
+      { entity_id: 'sensor.drybox_1_battery', friendly_name: 'Drybox 1 Battery', domain: 'sensor', device_class: 'battery', unit_of_measurement: '%', state: '90' },
+    ] as never);
+    getLocationSensors.mockResolvedValue([]);
+
+    const user = userEvent.setup();
+    render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
+
+    await user.click(await screen.findByText('Drybox 1 Humidity'));
+    await user.click(screen.getByRole('button', { name: /save/i }));
+
+    expect(await screen.findByText('Add the other sensors too?')).toBeInTheDocument();
+    const temperatureCheckbox = screen.getByRole('checkbox', { name: /drybox 1 temperature/i });
+    const batteryCheckbox = screen.getByRole('checkbox', { name: /drybox 1 battery/i });
+    expect(temperatureCheckbox).toBeChecked();
+    expect(batteryCheckbox).toBeChecked();
+
+    await user.click(screen.getByRole('button', { name: /confirm/i }));
+
+    await waitFor(() => expect(createSensor).toHaveBeenCalledTimes(3));
+    expect(createSensor).toHaveBeenNthCalledWith(1, expect.objectContaining({ entity_id: 'sensor.drybox_1_humidity' }));
+    expect(createSensor).toHaveBeenNthCalledWith(
+      2,
+      expect.objectContaining({
+        entity_id: 'sensor.drybox_1_temperature',
+        unit: 'degrees Celsius (integrated)'.slice(0, 16),
+      })
+    );
+    expect(createSensor).toHaveBeenNthCalledWith(3, expect.objectContaining({ entity_id: 'sensor.drybox_1_battery' }));
+  });
+
+  it('only adds sibling entities left checked in the auto-add dialog', async () => {
+    getSettings.mockResolvedValue(settings());
+    getEntities.mockResolvedValue([
+      { entity_id: 'sensor.drybox_1_humidity', friendly_name: 'Drybox 1 Humidity', domain: 'sensor', device_class: 'humidity', unit_of_measurement: '%', state: '40' },
+      { entity_id: 'sensor.drybox_1_temperature', friendly_name: 'Drybox 1 Temperature', domain: 'sensor', device_class: 'temperature', unit_of_measurement: '°C', state: '21.0' },
+      { entity_id: 'sensor.drybox_1_battery', friendly_name: 'Drybox 1 Battery', domain: 'sensor', device_class: 'battery', unit_of_measurement: '%', state: '90' },
+    ] as never);
+    getLocationSensors.mockResolvedValue([]);
+
+    const user = userEvent.setup();
+    render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
+
+    await user.click(await screen.findByText('Drybox 1 Humidity'));
+    await user.click(screen.getByRole('button', { name: /save/i }));
+
+    expect(await screen.findByText('Add the other sensors too?')).toBeInTheDocument();
+    await user.click(screen.getByRole('checkbox', { name: /drybox 1 battery/i }));
+
+    await user.click(screen.getByRole('button', { name: /confirm/i }));
+
+    await waitFor(() => expect(createSensor).toHaveBeenCalledTimes(2));
+    expect(createSensor).toHaveBeenNthCalledWith(1, expect.objectContaining({ entity_id: 'sensor.drybox_1_humidity' }));
+    expect(createSensor).toHaveBeenNthCalledWith(2, expect.objectContaining({ entity_id: 'sensor.drybox_1_temperature' }));
+    expect(createSensor).not.toHaveBeenCalledWith(expect.objectContaining({ entity_id: 'sensor.drybox_1_battery' }));
+  });
+
+  it('declining the auto-add prompt still saves the primary sensor, and says so on the button', async () => {
+    // "Cancel" here doesn't abandon the save — it saves the sensor the user
+    // picked and only skips the siblings — so the button must say that
+    // rather than the default "Cancel", which would read as discarding
+    // everything.
+    getSettings.mockResolvedValue(settings());
+    getEntities.mockResolvedValue([
+      { entity_id: 'sensor.drybox_1_humidity', friendly_name: 'Drybox 1 Humidity', domain: 'sensor', device_class: 'humidity', unit_of_measurement: '%', state: '40' },
+      { entity_id: 'sensor.drybox_1_temperature', friendly_name: 'Drybox 1 Temperature', domain: 'sensor', device_class: 'temperature', unit_of_measurement: '°C', state: '21.0' },
+    ] as never);
+    getLocationSensors.mockResolvedValue([]);
+
+    const user = userEvent.setup();
+    render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
+
+    await user.click(await screen.findByText('Drybox 1 Humidity'));
+    await user.click(screen.getByRole('button', { name: /save/i }));
+    const title = await screen.findByText('Add the other sensors too?');
+    const confirmDialog = within(title.closest('.bg-bambu-dark-secondary') as HTMLElement);
+
+    expect(confirmDialog.queryByRole('button', { name: /^cancel$/i })).not.toBeInTheDocument();
+    await user.click(confirmDialog.getByRole('button', { name: /only this one/i }));
+
+    await waitFor(() => expect(createSensor).toHaveBeenCalledTimes(1));
+    expect(createSensor).toHaveBeenCalledWith(expect.objectContaining({ entity_id: 'sensor.drybox_1_humidity' }));
+  });
+
+  it('applies the saved per-category defaults to auto-added sibling sensors', async () => {
+    vi.mocked(window.localStorage.getItem).mockReturnValue(
+      JSON.stringify({ temperature: true, battery: false, humidity: true })
+    );
+    getSettings.mockResolvedValue(
+      settings({
+        location_sensor_alert_defaults: JSON.stringify({
+          temperature: { alertAbove: '30', alertBelow: '', notifyOnAlert: true },
+          battery: { alertAbove: '', alertBelow: '15', notifyOnAlert: true },
+          humidity: { alertAbove: '', alertBelow: '', notifyOnAlert: false },
+        }),
+      })
+    );
+    getEntities.mockResolvedValue([
+      { entity_id: 'sensor.drybox_1_humidity', friendly_name: 'Drybox 1 Humidity', domain: 'sensor', device_class: 'humidity', unit_of_measurement: '%', state: '40' },
+      { entity_id: 'sensor.drybox_1_temperature', friendly_name: 'Drybox 1 Temperature', domain: 'sensor', device_class: 'temperature', unit_of_measurement: '°C', state: '21.0' },
+      { entity_id: 'sensor.drybox_1_battery', friendly_name: 'Drybox 1 Battery', domain: 'sensor', device_class: 'battery', unit_of_measurement: '%', state: '90' },
+    ] as never);
+    getLocationSensors.mockResolvedValue([]);
+
+    const user = userEvent.setup();
+    render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
+
+    await user.click(await screen.findByText('Drybox 1 Humidity'));
+    await user.click(screen.getByRole('button', { name: /save/i }));
+    await screen.findByText('Add the other sensors too?');
+    await user.click(screen.getByRole('button', { name: /confirm/i }));
+
+    await waitFor(() => expect(createSensor).toHaveBeenCalledTimes(3));
+    expect(createSensor).toHaveBeenNthCalledWith(
+      2,
+      expect.objectContaining({
+        entity_id: 'sensor.drybox_1_temperature',
+        alert_above: 30,
+        alert_below: null,
+        notify_on_alert: true,
+        show_on_card: true,
+      })
+    );
+    expect(createSensor).toHaveBeenNthCalledWith(
+      3,
+      expect.objectContaining({
+        entity_id: 'sensor.drybox_1_battery',
+        alert_above: null,
+        alert_below: 15,
+        notify_on_alert: true,
+        show_on_card: false,
+      })
+    );
+  });
+
+  it('prefills the form from saved category defaults when picking an entity while creating', async () => {
+    // Alert thresholds come from the server setting; only show-on-card is
+    // still per-browser (#2824 review round 4).
+    vi.mocked(window.localStorage.getItem).mockReturnValue(JSON.stringify({ humidity: false }));
+    getSettings.mockResolvedValue(
+      settings({
+        location_sensor_alert_defaults: JSON.stringify({
+          humidity: { alertAbove: '70', alertBelow: '20', notifyOnAlert: true },
+        }),
+      })
+    );
+    getEntities.mockResolvedValue([
+      { entity_id: 'sensor.drybox_1_humidity', friendly_name: 'Drybox 1 Humidity', domain: 'sensor', device_class: 'humidity', unit_of_measurement: '%', state: '40' },
+    ] as never);
+    getLocationSensors.mockResolvedValue([]);
+
+    const user = userEvent.setup();
+    render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
+
+    await user.click(await screen.findByText('Drybox 1 Humidity'));
+
+    const [aboveInput, belowInput] = screen.getAllByRole('spinbutton');
+    expect(aboveInput).toHaveValue(70);
+    expect(belowInput).toHaveValue(20);
+    expect(screen.getByRole('checkbox', { name: /show on filament card/i })).not.toBeChecked();
+    expect(screen.getByRole('checkbox', { name: /send a notification/i })).toBeChecked();
+  });
+
+  it('does not overwrite an edited sensor with saved defaults when re-selecting its entity', async () => {
+    vi.mocked(window.localStorage.getItem).mockReturnValue(
+      JSON.stringify({
+        temperature: { alertAbove: '', alertBelow: '', notifyOnAlert: false, showOnCard: true },
+        humidity: { alertAbove: '999', alertBelow: '111', notifyOnAlert: true, showOnCard: false },
+        battery: { alertAbove: '', alertBelow: '', notifyOnAlert: false, showOnCard: true },
+      })
+    );
+    getSettings.mockResolvedValue(settings());
+    getEntities.mockResolvedValue([
+      { entity_id: 'sensor.drybox_1_humidity', friendly_name: 'Drybox 1 Humidity', domain: 'sensor', device_class: 'humidity', unit_of_measurement: '%', state: '40' },
+    ] as never);
+    getLocationSensors.mockResolvedValue([]);
+
+    const user = userEvent.setup();
+    render(
+      <LocationHASensorModal
+        sensor={
+          {
+            id: 1,
+            location_id: 7,
+            name: 'sensor.drybox_1_humidity',
+            entity_id: 'sensor.drybox_1_humidity',
+            kind: 'numeric',
+            device_class: 'humidity',
+            unit: '%',
+            alert_state: null,
+            alert_above: 55,
+            alert_below: null,
+            notify_on_alert: false,
+            show_on_card: true,
+            sort_order: 0,
+            last_state: null,
+            last_changed: null,
+            last_checked: null,
+            created_at: '',
+            updated_at: '',
+          } as never
+        }
+        locations={LOCATIONS}
+        onClose={() => {}}
+      />
+    );
+
+    await user.click(await screen.findByText('Drybox 1 Humidity'));
+
+    const [aboveInput] = screen.getAllByRole('spinbutton');
+    expect(aboveInput).toHaveValue(55);
+  });
+
+  it('says so when no sibling entities are found for the first sensor of a location', async () => {
+    getSettings.mockResolvedValue(settings());
+    getEntities.mockResolvedValue([
+      { entity_id: 'sensor.drybox_1_humidity', friendly_name: 'Drybox 1 Humidity', domain: 'sensor', device_class: 'humidity', unit_of_measurement: '%', state: '40' },
+    ] as never);
+    getLocationSensors.mockResolvedValue([]);
+
+    const user = userEvent.setup();
+    render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
+
+    await user.click(await screen.findByText('Drybox 1 Humidity'));
+    await user.click(screen.getByRole('button', { name: /save/i }));
+
+    expect(screen.queryByText('Add the other sensors too?')).not.toBeInTheDocument();
+    expect(
+      await screen.findByText(/No matching temperature, humidity, or battery sensors found/)
+    ).toBeInTheDocument();
+    await waitFor(() => expect(createSensor).toHaveBeenCalledTimes(1));
+  });
+});
+
+describe('LocationHASensorModal — overwrite path (#2824)', () => {
+  beforeEach(() => {
+    getSettings.mockReset();
+    getEntities.mockReset();
+    getEntities.mockResolvedValue([]);
+    getLocationSensors.mockReset();
+    getLocationSensors.mockResolvedValue([]);
+    createSensor.mockReset();
+    createSensor.mockResolvedValue({} as never);
+    updateSensor.mockReset();
+    updateSensor.mockResolvedValue({} as never);
+    vi.mocked(window.localStorage.getItem).mockReset();
+  });
+
+  const existingSensor = {
+    id: 42,
+    location_id: 7,
+    name: 'Old Drybox Temp',
+    entity_id: 'sensor.drybox_1_temp_old',
+    kind: 'numeric',
+    device_class: 'temperature',
+    unit: '°C',
+    alert_state: null,
+    alert_above: null,
+    alert_below: null,
+    notify_on_alert: false,
+    show_on_card: true,
+  };
+
+  it('PATCHes the existing sensor onto the new entity instead of deleting and recreating', async () => {
+    // Regression: delete-then-create left a window where, if the create
+    // failed after the delete succeeded, the location's binding was gone
+    // with nothing in its place. A single PATCH avoids that window, and
+    // this also proves nothing calls deleteLocationHASensor on this path.
+    getSettings.mockResolvedValue(settings());
+    getEntities.mockResolvedValue([
+      {
+        entity_id: 'sensor.drybox_1_temp_new',
+        friendly_name: 'Drybox 1 Temp (new)',
+        domain: 'sensor',
+        device_class: 'temperature',
+        unit_of_measurement: '°C',
+        state: '22.0',
+      },
+    ] as never);
+    getLocationSensors.mockResolvedValue([existingSensor] as never);
+
+    const onClose = vi.fn();
+    const user = userEvent.setup();
+    render(<LocationHASensorModal locations={LOCATIONS} onClose={onClose} />);
+
+    await user.click(await screen.findByText('Drybox 1 Temp (new)'));
+    await user.click(screen.getByRole('button', { name: /save/i }));
+
+    expect(await screen.findByText('Replace existing sensor?')).toBeInTheDocument();
+    await user.click(screen.getByRole('button', { name: 'Confirm' }));
+
+    await waitFor(() => expect(updateSensor).toHaveBeenCalledTimes(1));
+    expect(updateSensor).toHaveBeenCalledWith(
+      42,
+      expect.objectContaining({ entity_id: 'sensor.drybox_1_temp_new', name: 'Drybox 1 Temp (new)' })
+    );
+    expect(createSensor).not.toHaveBeenCalled();
+    expect(onClose).toHaveBeenCalled();
+  });
+
+  it('does not close on Escape or backdrop click while the overwrite PATCH is in flight', async () => {
+    getSettings.mockResolvedValue(settings());
+    getEntities.mockResolvedValue([
+      {
+        entity_id: 'sensor.drybox_1_temp_new',
+        friendly_name: 'Drybox 1 Temp (new)',
+        domain: 'sensor',
+        device_class: 'temperature',
+        unit_of_measurement: '°C',
+        state: '22.0',
+      },
+    ] as never);
+    getLocationSensors.mockResolvedValue([existingSensor] as never);
+    let resolveUpdate: (() => void) | undefined;
+    updateSensor.mockImplementation(
+      () =>
+        new Promise((resolve) => {
+          resolveUpdate = () => resolve({} as never);
+        })
+    );
+
+    const onClose = vi.fn();
+    const user = userEvent.setup();
+    const { container } = render(<LocationHASensorModal locations={LOCATIONS} onClose={onClose} />);
+
+    await user.click(await screen.findByText('Drybox 1 Temp (new)'));
+    await user.click(screen.getByRole('button', { name: /save/i }));
+    await screen.findByText('Replace existing sensor?');
+    await user.click(screen.getByRole('button', { name: 'Confirm' }));
+
+    await waitFor(() => expect(updateSensor).toHaveBeenCalled());
+
+    await user.keyboard('{Escape}');
+    const backdrop = container.querySelector('.fixed.inset-0.bg-black\\/70');
+    if (backdrop) await user.click(backdrop);
+
+    expect(onClose).not.toHaveBeenCalled();
+
+    resolveUpdate?.();
+    await waitFor(() => expect(onClose).toHaveBeenCalled());
+  });
+});

+ 443 - 0
frontend/src/__tests__/components/LocationSensorOptionsModal.test.tsx

@@ -0,0 +1,443 @@
+import { act, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { api } from '../../api/client';
+import { LocationSensorOptionsModal } from '../../components/LocationSensorOptionsModal';
+import { render } from '../utils';
+import {
+  defaultLocationSensorDefaults,
+  serializeLocationSensorAlertDefaults,
+} from '../../utils/locationSensorDefaults';
+
+vi.mock('../../api/client', async () => {
+  const actual = await vi.importActual<typeof import('../../api/client')>('../../api/client');
+  return {
+    ...actual,
+    api: {
+      ...actual.api,
+      getLocationHASensors: vi.fn(),
+      getBindableLocationHAEntities: vi.fn(),
+      updateLocationHASensor: vi.fn(),
+      getSettings: vi.fn(),
+      updateSettings: vi.fn(),
+    },
+  };
+});
+
+const getLocationSensors = vi.mocked(api.getLocationHASensors);
+const getEntities = vi.mocked(api.getBindableLocationHAEntities);
+const updateSensor = vi.mocked(api.updateLocationHASensor);
+const getSettings = vi.mocked(api.getSettings);
+const updateSettings = vi.mocked(api.updateSettings);
+
+describe('LocationSensorOptionsModal', () => {
+  beforeEach(() => {
+    vi.mocked(window.localStorage.getItem).mockReset();
+    vi.mocked(window.localStorage.setItem).mockReset();
+    getLocationSensors.mockReset();
+    getEntities.mockReset();
+    updateSensor.mockReset();
+    getSettings.mockReset();
+    updateSettings.mockReset();
+    getEntities.mockResolvedValue([]);
+    getSettings.mockResolvedValue({ location_sensor_poll_interval: 120 } as never);
+    updateSettings.mockResolvedValue({} as never);
+  });
+
+  it('shows a section for each of the three auto-add categories', async () => {
+    render(<LocationSensorOptionsModal onClose={() => {}} />);
+
+    expect(await screen.findByText('Temperature')).toBeInTheDocument();
+    expect(screen.getByText('Humidity')).toBeInTheDocument();
+    expect(screen.getByText('Battery')).toBeInTheDocument();
+  });
+
+  // The alert thresholds are server-backed (#2824 review round 4): they seed
+  // the rule written onto each sensor row, so they must not differ per browser
+  // and have to survive a backup/restore. Only the show-on-card default is
+  // still local, because show_on_card is decided per sensor.
+  it('saves the entered alert defaults to the server and closes', async () => {
+    const onClose = vi.fn();
+    const user = userEvent.setup();
+    render(<LocationSensorOptionsModal onClose={onClose} />);
+
+    const aboveInputs = screen.getAllByText('Above °C');
+    expect(aboveInputs.length).toBeGreaterThan(0);
+
+    const inputs = screen.getAllByRole('spinbutton');
+    await user.clear(inputs[0]);
+    await user.type(inputs[0], '35');
+
+    await user.click(screen.getByRole('button', { name: /save/i }));
+
+    await waitFor(() =>
+      expect(updateSettings).toHaveBeenCalledWith(
+        expect.objectContaining({
+          location_sensor_alert_defaults: expect.stringContaining('"alertAbove":"35"'),
+        })
+      )
+    );
+    expect(onClose).toHaveBeenCalled();
+  });
+
+  it('keeps the show-on-card default in localStorage, without the alert fields', async () => {
+    const user = userEvent.setup();
+    render(<LocationSensorOptionsModal onClose={() => {}} />);
+
+    await screen.findByText('Battery');
+    await user.click(screen.getByRole('button', { name: /save/i }));
+
+    await waitFor(() =>
+      expect(window.localStorage.setItem).toHaveBeenCalledWith(
+        'bambuddy-location-sensor-show-on-card-defaults',
+        expect.stringContaining('"temperature":true')
+      )
+    );
+    const written = vi
+      .mocked(window.localStorage.setItem)
+      .mock.calls.find((call) => call[0] === 'bambuddy-location-sensor-show-on-card-defaults')?.[1];
+    expect(written).not.toContain('alertAbove');
+    expect(written).not.toContain('notifyOnAlert');
+  });
+
+  it('does not offer an "above" threshold for the battery section', async () => {
+    render(<LocationSensorOptionsModal onClose={() => {}} />);
+
+    await screen.findByText('Battery');
+
+    expect(screen.getAllByText(/^Above (°C|%)$/)).toHaveLength(2);
+    expect(screen.getAllByText(/^Below (°C|%)$/)).toHaveLength(3);
+    expect(screen.getAllByRole('spinbutton')).toHaveLength(6);
+  });
+
+  it('clears a stale saved "above" value for battery on save', async () => {
+    getSettings.mockResolvedValue({
+      location_sensor_poll_interval: 120,
+      location_sensor_alert_defaults: JSON.stringify({
+        temperature: { alertAbove: '', alertBelow: '', notifyOnAlert: false },
+        humidity: { alertAbove: '', alertBelow: '', notifyOnAlert: false },
+        battery: { alertAbove: '95', alertBelow: '15', notifyOnAlert: true },
+      }),
+    } as never);
+    const user = userEvent.setup();
+    render(<LocationSensorOptionsModal onClose={() => {}} />);
+
+    await screen.findByText('Battery');
+    await user.click(screen.getByRole('button', { name: /save/i }));
+
+    await waitFor(() =>
+      expect(updateSettings).toHaveBeenCalledWith(
+        expect.objectContaining({
+          location_sensor_alert_defaults: expect.stringContaining('"battery":{"alertAbove":"","alertBelow":"15"'),
+        })
+      )
+    );
+  });
+
+  it('saves the chosen above/below/optimal alert colors', async () => {
+    const user = userEvent.setup();
+    render(<LocationSensorOptionsModal onClose={() => {}} />);
+
+    await screen.findByText('Battery');
+
+    await user.selectOptions(screen.getByLabelText(/above threshold color/i), 'orange');
+    await user.selectOptions(screen.getByLabelText(/below threshold color/i), 'purple');
+    await user.selectOptions(screen.getByLabelText(/optimal value color/i), 'blue');
+    await user.click(screen.getByRole('button', { name: /save/i }));
+
+    expect(window.localStorage.setItem).toHaveBeenCalledWith('bambuddy-location-sensor-alert-above-color', 'orange');
+    expect(window.localStorage.setItem).toHaveBeenCalledWith('bambuddy-location-sensor-alert-below-color', 'purple');
+    expect(window.localStorage.setItem).toHaveBeenCalledWith('bambuddy-location-sensor-alert-optimal-color', 'blue');
+  });
+
+  it('loads the current poll interval and saves a changed value', async () => {
+    getSettings.mockResolvedValue({ location_sensor_poll_interval: 300 } as never);
+    const user = userEvent.setup();
+    render(<LocationSensorOptionsModal onClose={() => {}} />);
+
+    const input = await screen.findByLabelText(/update interval/i);
+    await waitFor(() => expect(input).toHaveValue(300));
+
+    await user.clear(input);
+    await user.type(input, '90');
+    await user.click(screen.getByRole('button', { name: /save/i }));
+
+    await waitFor(() =>
+      expect(updateSettings).toHaveBeenCalledWith(expect.objectContaining({ location_sensor_poll_interval: 90 }))
+    );
+  });
+
+  // Both server-backed fields are seeded into local state by an effect, so a
+  // settings response landing after the user has started typing used to
+  // overwrite the edit — a cleared-and-retyped threshold came out as "3035".
+  // Covers the interval too, which had the same shape before this guard.
+  it('does not overwrite an in-progress edit when the settings response lands late', async () => {
+    let resolveSettings: (value: unknown) => void = () => {};
+    getSettings.mockReturnValue(
+      new Promise((resolve) => {
+        resolveSettings = resolve;
+      }) as never
+    );
+
+    const user = userEvent.setup();
+    render(<LocationSensorOptionsModal onClose={() => {}} />);
+
+    // Form is up on the built-ins; edit before the server has answered.
+    const inputs = screen.getAllByRole('spinbutton');
+    await user.clear(inputs[0]);
+    await user.type(inputs[0], '35');
+
+    // Resolve and let the query actually propagate before asserting. A bare
+    // waitFor would pass on its first tick — before the response reaches the
+    // effect — and so would succeed even with the guard removed.
+    await act(async () => {
+      resolveSettings({
+        location_sensor_poll_interval: 900,
+        location_sensor_alert_defaults: JSON.stringify({
+          temperature: { alertAbove: '30', alertBelow: '20', notifyOnAlert: false },
+        }),
+      });
+      await new Promise((resolve) => setTimeout(resolve, 50));
+    });
+
+    // The late response must not put the server's 30 back over the typed 35.
+    expect(screen.getAllByRole('spinbutton')[0]).toHaveValue(35);
+  });
+
+  it('still seeds the fields the user did not touch when the settings response lands late', async () => {
+    // The other half of the guard above. Skipping the seed outright because a
+    // keystroke beat the response would leave every untouched field on the
+    // built-ins, and Save would then write those over the server's values for
+    // fields the user never saw. "Seeded" and "touched" are tracked apart so
+    // the seed still lands everywhere the user has not typed.
+    let resolveSettings: (value: unknown) => void = () => {};
+    getSettings.mockReturnValue(
+      new Promise((resolve) => {
+        resolveSettings = resolve;
+      }) as never
+    );
+
+    const user = userEvent.setup();
+    render(<LocationSensorOptionsModal onClose={() => {}} />);
+
+    const inputs = screen.getAllByRole('spinbutton');
+    await user.clear(inputs[0]);
+    await user.type(inputs[0], '35');
+
+    await act(async () => {
+      resolveSettings({
+        location_sensor_poll_interval: 900,
+        location_sensor_alert_defaults: JSON.stringify({
+          humidity: { alertAbove: '55', alertBelow: '25', notifyOnAlert: true },
+        }),
+      });
+      await new Promise((resolve) => setTimeout(resolve, 50));
+    });
+
+    await user.click(screen.getByRole('button', { name: /save/i }));
+
+    await waitFor(() => expect(updateSettings).toHaveBeenCalled());
+    const patch = updateSettings.mock.calls[0][0] as Record<string, unknown>;
+    // Seeded, so it matches the server and is left out of the patch entirely.
+    // Unseeded it would still read 120 and be sent as a change nobody made.
+    expect(patch.location_sensor_poll_interval).toBeUndefined();
+    // The typed value survived...
+    expect(patch.location_sensor_alert_defaults).toContain('"alertAbove":"35"');
+    // ...and the category never touched kept the server's 55, not the built-in 30.
+    expect(patch.location_sensor_alert_defaults).toContain('"alertAbove":"55"');
+  });
+
+  it('does not call updateSettings when nothing on the server side changed', async () => {
+    // Both server-backed fields already match what the form would submit, so
+    // an untouched Save must not issue a PATCH that needs admin rights.
+    getSettings.mockResolvedValue({
+      location_sensor_poll_interval: 120,
+      location_sensor_alert_defaults: serializeLocationSensorAlertDefaults(defaultLocationSensorDefaults()),
+    } as never);
+    const onClose = vi.fn();
+    const user = userEvent.setup();
+    render(<LocationSensorOptionsModal onClose={onClose} />);
+
+    await screen.findByLabelText(/update interval/i);
+    await user.click(screen.getByRole('button', { name: /save/i }));
+
+    await waitFor(() => expect(onClose).toHaveBeenCalled());
+    expect(updateSettings).not.toHaveBeenCalled();
+  });
+
+  it('shows an error and keeps the modal open when saving a changed interval fails', async () => {
+    updateSettings.mockRejectedValue(new Error('Forbidden'));
+    const onClose = vi.fn();
+    const user = userEvent.setup();
+    render(<LocationSensorOptionsModal onClose={onClose} />);
+
+    const input = await screen.findByLabelText(/update interval/i);
+    await user.clear(input);
+    await user.type(input, '90');
+    await user.click(screen.getByRole('button', { name: /save/i }));
+
+    expect(await screen.findByText('Forbidden')).toBeInTheDocument();
+    expect(onClose).not.toHaveBeenCalled();
+    // The server call happens before the localStorage writes, so a failed
+    // PATCH must leave every local preference untouched — the error toast
+    // says nothing was saved, and that has to stay true.
+    const writtenKeys = vi.mocked(window.localStorage.setItem).mock.calls.map((call) => call[0]);
+    expect(writtenKeys).not.toContain('bambuddy-location-sensor-show-on-card-defaults');
+    expect(writtenKeys).not.toContain('bambuddy-location-sensor-colorize-values');
+    expect(writtenKeys).not.toContain('bambuddy-location-sensor-alert-above-color');
+    expect(writtenKeys).not.toContain('bambuddy-location-sensor-alert-below-color');
+    expect(writtenKeys).not.toContain('bambuddy-location-sensor-alert-optimal-color');
+  });
+
+  it('clamps a poll interval below the 60s minimum on blur', async () => {
+    const user = userEvent.setup();
+    render(<LocationSensorOptionsModal onClose={() => {}} />);
+
+    const input = await screen.findByLabelText(/update interval/i);
+    await waitFor(() => expect(input).toHaveValue(120));
+
+    await user.clear(input);
+    await user.type(input, '10');
+    await user.tab();
+
+    expect(input).toHaveValue(60);
+  });
+
+  it('disables the color pickers when colorizing is turned off', async () => {
+    const user = userEvent.setup();
+    render(<LocationSensorOptionsModal onClose={() => {}} />);
+
+    await screen.findByText('Battery');
+
+    await user.click(screen.getByLabelText(/colorize sensor values/i));
+
+    expect(screen.getByLabelText(/above threshold color/i)).toBeDisabled();
+    expect(screen.getByLabelText(/below threshold color/i)).toBeDisabled();
+    expect(screen.getByLabelText(/optimal value color/i)).toBeDisabled();
+  });
+
+  it('asks for confirmation before overwriting existing sensors, then applies the configured values', async () => {
+    getLocationSensors.mockResolvedValue([
+      { id: 1, device_class: 'temperature', entity_id: 'sensor.drybox_1_temperature' } as never,
+      { id: 2, device_class: 'humidity', entity_id: 'sensor.drybox_1_humidity' } as never,
+      { id: 3, device_class: 'battery', entity_id: 'sensor.drybox_1_battery' } as never,
+      { id: 4, device_class: 'door', entity_id: 'binary_sensor.drybox_1_door' } as never,
+    ]);
+    getEntities.mockResolvedValue([
+      { entity_id: 'sensor.drybox_1_temperature', friendly_name: 'Drybox 1 Temperature' } as never,
+      { entity_id: 'sensor.drybox_1_battery', friendly_name: 'Drybox 1 Battery' } as never,
+    ]);
+    updateSensor.mockResolvedValue({} as never);
+    const onClose = vi.fn();
+    const user = userEvent.setup();
+    render(<LocationSensorOptionsModal onClose={onClose} />);
+
+    await screen.findByText('Battery');
+    const inputs = screen.getAllByRole('spinbutton');
+    await user.clear(inputs[0]);
+    await user.type(inputs[0], '35');
+
+    await user.click(screen.getByRole('button', { name: /^reset$/i }));
+    expect(updateSensor).not.toHaveBeenCalled();
+    expect(await screen.findByText(/cannot be undone/i)).toBeInTheDocument();
+
+    await user.click(screen.getAllByRole('button', { name: /^reset$/i })[1]);
+
+    await waitFor(() => expect(updateSensor).toHaveBeenCalledTimes(3));
+    expect(updateSensor).toHaveBeenCalledWith(1, expect.objectContaining({ alert_above: 35, name: 'Drybox 1 Temperature' }));
+    expect(updateSensor).toHaveBeenCalledWith(3, expect.objectContaining({ alert_above: null, name: 'Drybox 1 Battery' }));
+    // Sensor 2's entity isn't in the Home Assistant list (e.g. currently
+    // unreachable) — its name is left untouched rather than cleared.
+    expect(updateSensor).toHaveBeenCalledWith(2, expect.not.objectContaining({ name: expect.anything() }));
+    expect(onClose).toHaveBeenCalled();
+
+    expect(updateSettings).toHaveBeenCalledWith(
+      expect.objectContaining({
+        location_sensor_alert_defaults: expect.stringContaining('"alertAbove":"35"'),
+      })
+    );
+  });
+
+  it('saves nothing when the reset fails part-way', async () => {
+    // Reset used to save the options first and rewrite the sensors after, so a
+    // rejected sensor PATCH left the settings and the six local preferences
+    // saved behind an error toast that said nothing had been. Sensors first,
+    // options after: a failure now means the toast is true.
+    getLocationSensors.mockResolvedValue([{ id: 1, device_class: 'temperature' } as never]);
+    getEntities.mockResolvedValue([]);
+    updateSensor.mockRejectedValue(new Error('Forbidden'));
+    const onClose = vi.fn();
+    const user = userEvent.setup();
+    render(<LocationSensorOptionsModal onClose={onClose} />);
+
+    await screen.findByText('Battery');
+    const inputs = screen.getAllByRole('spinbutton');
+    await user.clear(inputs[0]);
+    await user.type(inputs[0], '35');
+
+    await user.click(screen.getByRole('button', { name: /^reset$/i }));
+    await screen.findByText(/cannot be undone/i);
+    await user.click(screen.getAllByRole('button', { name: /^reset$/i })[1]);
+
+    await waitFor(() => expect(updateSensor).toHaveBeenCalled());
+    expect(updateSettings).not.toHaveBeenCalled();
+    // The render itself writes unrelated keys (theme), so scope this to the
+    // preferences Save owns.
+    const written = vi.mocked(window.localStorage.setItem).mock.calls.map(([key]) => key);
+    expect(written.filter((key) => String(key).startsWith('bambuddy-location-sensor'))).toEqual([]);
+    expect(onClose).not.toHaveBeenCalled();
+  });
+
+  it('does not overwrite anything when the reset confirmation is cancelled', async () => {
+    getLocationSensors.mockResolvedValue([{ id: 1, device_class: 'temperature' } as never]);
+    const user = userEvent.setup();
+    render(<LocationSensorOptionsModal onClose={() => {}} />);
+
+    await screen.findByText('Battery');
+    await user.click(screen.getByRole('button', { name: /^reset$/i }));
+    await screen.findByText(/cannot be undone/i);
+
+    const cancelButtons = screen.getAllByRole('button', { name: /cancel/i });
+    await user.click(cancelButtons[cancelButtons.length - 1]);
+
+    expect(updateSensor).not.toHaveBeenCalled();
+  });
+
+  it('dismissing the reset confirm by clicking its own overlay does not close the Options dialog', async () => {
+    // Regression: ConfirmModal used to render inside the Options overlay's
+    // onClick=onClose div. ConfirmModal's own overlay doesn't stop
+    // propagation, so a click meant only to dismiss it bubbled up and closed
+    // Options too, dropping whatever the user had already changed.
+    getLocationSensors.mockResolvedValue([{ id: 1, device_class: 'temperature' } as never]);
+    const onClose = vi.fn();
+    const user = userEvent.setup();
+    const { container } = render(<LocationSensorOptionsModal onClose={onClose} />);
+
+    await screen.findByText('Battery');
+    await user.click(screen.getByRole('button', { name: /^reset$/i }));
+    await screen.findByText(/cannot be undone/i);
+
+    const overlays = container.querySelectorAll('.fixed.inset-0');
+    expect(overlays.length).toBe(2);
+    const confirmOverlay = overlays[overlays.length - 1];
+    await user.click(confirmOverlay);
+
+    expect(screen.queryByText(/cannot be undone/i)).toBeNull();
+    expect(onClose).not.toHaveBeenCalled();
+  });
+
+  it('does not persist anything when cancelled', async () => {
+    const onClose = vi.fn();
+    const user = userEvent.setup();
+    render(<LocationSensorOptionsModal onClose={onClose} />);
+
+    await user.click(screen.getByRole('button', { name: /cancel/i }));
+
+    expect(window.localStorage.setItem).not.toHaveBeenCalledWith(
+      'bambuddy-location-sensor-auto-add-defaults',
+      expect.anything()
+    );
+    expect(onClose).toHaveBeenCalled();
+  });
+});

+ 60 - 0
frontend/src/__tests__/components/LocationsModal.test.tsx

@@ -241,3 +241,63 @@ describe('LocationsModal', () => {
     });
   });
 });
+
+describe('LocationsModal — startCreating', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    vi.mocked(api.getLocations).mockResolvedValue(locations);
+    vi.mocked(api.createLocation).mockResolvedValue({
+      id: 3,
+      name: 'Garage',
+      identifier: null,
+      spool_count: 0,
+      created_at: '2026-01-01',
+      updated_at: '2026-01-01',
+    });
+  });
+
+  function renderCreatingModal(withPickLocation: boolean) {
+    const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+    return render(
+      <QueryClientProvider client={client}>
+        <MemoryRouter>
+          <LocationsModal
+            open
+            onClose={mockOnClose}
+            onPickLocation={withPickLocation ? mockOnPickLocation : undefined}
+            startCreating
+          />
+        </MemoryRouter>
+      </QueryClientProvider>,
+    );
+  }
+
+  it('calls onPickLocation and onClose on a successful create', async () => {
+    const user = userEvent.setup();
+    renderCreatingModal(true);
+
+    const input = await screen.findByLabelText(/name|locations\.name/i);
+    await user.type(input, 'Garage');
+    await user.click(screen.getByRole('button', { name: /save|common\.save/i }));
+
+    await waitFor(() => expect(mockOnPickLocation).toHaveBeenCalledWith(3));
+    expect(mockOnClose).toHaveBeenCalledTimes(1);
+  });
+
+  it('still closes on a successful create when onPickLocation is not provided', async () => {
+    // Regression: gating the close on onPickLocation being set left editorOpen
+    // false with open still true — nothing left to render (startCreating has
+    // no location-list view to fall back to), but the caller never told to
+    // close, so the modal effectively vanished stuck "open".
+    const user = userEvent.setup();
+    renderCreatingModal(false);
+
+    const input = await screen.findByLabelText(/name|locations\.name/i);
+    await user.type(input, 'Garage');
+    await user.click(screen.getByRole('button', { name: /save|common\.save/i }));
+
+    await waitFor(() => expect(api.createLocation).toHaveBeenCalledWith({ name: 'Garage' }));
+    await waitFor(() => expect(mockOnClose).toHaveBeenCalledTimes(1));
+    expect(mockOnPickLocation).not.toHaveBeenCalled();
+  });
+});

+ 25 - 0
frontend/src/__tests__/components/PrinterHASensorRow.test.tsx

@@ -98,6 +98,31 @@ describe('PrinterHASensorRow', () => {
     expect(await screen.findByText('41.2 °C')).toBeInTheDocument();
   });
 
+  it('shows the Battery icon for a battery-class reading, not the generic Gauge', async () => {
+    // The printer icon map never had its own "battery" entry — merging it
+    // with the location-sensor map added one, deliberately, for both
+    // consumers. This pins that the printer row picks it up rather than
+    // silently keeping the old Gauge fallback.
+    getReadings.mockResolvedValue([
+      reading({
+        kind: 'numeric',
+        device_class: 'battery',
+        entity_id: 'sensor.enclosure_sensor_battery',
+        name: 'Enclosure Sensor Battery',
+        unit: '%',
+        state: '87',
+        value: 87,
+      }),
+    ]);
+
+    render(<PrinterHASensorRow printerId={4} />);
+
+    const value = await screen.findByText('87 %');
+    const row = value.closest('div.flex')!;
+    expect(row.querySelector('.lucide-battery')).toBeInTheDocument();
+    expect(row.querySelector('.lucide-gauge')).not.toBeInTheDocument();
+  });
+
   it('reports an unreachable sensor as unavailable rather than as a state', async () => {
     // The pill must not read "Closed" for a door contact that dropped off the
     // network — that is the one wrong answer with real consequences.

+ 215 - 0
frontend/src/__tests__/pages/InventoryPageLocationSensorRequests.test.tsx

@@ -0,0 +1,215 @@
+/**
+ * Location-sensor readings should cost one request per location, not two.
+ *
+ * Card view (SpoolLocationFooter) and the table's Temperature/Humidity/
+ * Battery columns used to each fetch their own copy — the footer under a
+ * 'cardOnly' key, the table under an 'all' key — so a location with sensors
+ * bound cost two requests per poll interval whenever a card was on screen,
+ * and the table's 'all' request fired even with all three sensor columns
+ * hidden, which is the default. Both queries now share one key and one
+ * unfiltered fetch; the footer filters to show_on_card itself.
+ */
+
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import InventoryPageRouter from '../../pages/InventoryPage';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+const mockSpool = {
+  id: 1,
+  material: 'PLA',
+  subtype: null,
+  brand: 'Polymaker',
+  color_name: 'Red',
+  rgba: 'FF0000FF',
+  label_weight: 1000,
+  core_weight: 250,
+  weight_used: 100,
+  slicer_filament: null,
+  slicer_filament_name: null,
+  nozzle_temp_min: null,
+  nozzle_temp_max: null,
+  note: null,
+  added_full: null,
+  last_used: null,
+  encode_time: null,
+  tag_uid: null,
+  tray_uuid: null,
+  data_origin: null,
+  tag_type: null,
+  archived_at: null,
+  created_at: '2025-01-01T00:00:00Z',
+  updated_at: '2025-01-01T00:00:00Z',
+  k_profiles: [],
+  cost_per_kg: null,
+  last_scale_weight: null,
+  last_weighed_at: null,
+  location_id: 7,
+  storage_location: 'Drybox 1',
+};
+
+const mockSpoolAtUnsensoredLocation = {
+  ...mockSpool,
+  id: 2,
+  brand: 'eSun',
+  location_id: 8,
+  storage_location: 'Shelf 2',
+};
+
+const mockSensor = {
+  id: 1,
+  location_id: 7,
+  name: 'Drybox 1 Temperature',
+  entity_id: 'sensor.drybox_1_temperature',
+  kind: 'numeric',
+  device_class: 'temperature',
+  unit: '°C',
+  alert_state: null,
+  alert_above: null,
+  alert_below: null,
+  notify_on_alert: false,
+  show_on_card: true,
+  sort_order: 0,
+  last_state: '24.5',
+  last_changed: '2025-01-01T00:00:00Z',
+  created_at: '2025-01-01T00:00:00Z',
+  updated_at: '2025-01-01T00:00:00Z',
+};
+
+const mockReading = {
+  id: 1,
+  name: 'Drybox 1 Temperature',
+  entity_id: 'sensor.drybox_1_temperature',
+  kind: 'numeric',
+  device_class: 'temperature',
+  unit: '°C',
+  state: '24.5',
+  value: 24.5,
+  alerting: false,
+  reachable: true,
+  alert_state: null,
+  alert_above: null,
+  alert_below: null,
+  last_changed: '2025-01-01T00:00:00Z',
+  show_on_card: true,
+};
+
+const COLUMN_CONFIG_KEY = 'bambuddy-inventory-columns';
+
+describe('InventoryPage - location sensor readings request count', () => {
+  let readingsRequestCount: number;
+
+  beforeEach(() => {
+    vi.mocked(window.localStorage.getItem).mockReset();
+    vi.mocked(window.localStorage.setItem).mockReset();
+    readingsRequestCount = 0;
+    server.use(
+      http.get('/api/v1/inventory/spools', () => HttpResponse.json([mockSpool])),
+      http.get('/api/v1/location-ha-sensors/', () => HttpResponse.json([mockSensor])),
+      http.get('/api/v1/location-ha-sensors/by-location/7/readings', () => {
+        readingsRequestCount += 1;
+        return HttpResponse.json([mockReading]);
+      }),
+    );
+  });
+
+  it('does not fetch readings in table view with sensor columns hidden (the default)', async () => {
+    render(<InventoryPageRouter />);
+
+    await waitFor(() => {
+      expect(screen.getAllByText('Polymaker').length).toBeGreaterThan(0);
+    });
+    // Give any stray request a moment to land before asserting its absence.
+    await new Promise((r) => setTimeout(r, 50));
+    expect(readingsRequestCount).toBe(0);
+  });
+
+  it('fetches readings exactly once in table view when a sensor column is visible', async () => {
+    vi.mocked(window.localStorage.getItem).mockImplementation((key: string) =>
+      key === COLUMN_CONFIG_KEY
+        ? JSON.stringify([{ id: 'temperature', label: 'Temperature', visible: true }])
+        : null
+    );
+
+    render(<InventoryPageRouter />);
+
+    await waitFor(() => expect(screen.getByText('24.50 °C')).toBeInTheDocument());
+    await new Promise((r) => setTimeout(r, 50));
+    expect(readingsRequestCount).toBe(1);
+  });
+
+  it('fetches readings exactly once in card view, shared between the table query and the footer', async () => {
+    const user = userEvent.setup();
+    render(<InventoryPageRouter />);
+
+    await waitFor(() => expect(screen.getAllByText('Polymaker').length).toBeGreaterThan(0));
+    await user.click(screen.getByText('Cards'));
+
+    await waitFor(() => expect(screen.getByText('24.50 °C')).toBeInTheDocument());
+    await new Promise((r) => setTimeout(r, 50));
+    expect(readingsRequestCount).toBe(1);
+  });
+
+  it('hides a sensor with show_on_card=false from the card footer without a second request', async () => {
+    server.use(
+      http.get('/api/v1/location-ha-sensors/by-location/7/readings', () => {
+        readingsRequestCount += 1;
+        return HttpResponse.json([{ ...mockReading, show_on_card: false }]);
+      }),
+    );
+    const user = userEvent.setup();
+    render(<InventoryPageRouter />);
+
+    await waitFor(() => expect(screen.getAllByText('Polymaker').length).toBeGreaterThan(0));
+    await user.click(screen.getByText('Cards'));
+
+    // The footer never renders — its one sensor is hidden from cards — but
+    // that must be a client-side filter, not a second, differently-scoped
+    // request.
+    await new Promise((r) => setTimeout(r, 50));
+    expect(screen.queryByText('24.50 °C')).toBeNull();
+    expect(readingsRequestCount).toBe(1);
+  });
+
+  it('never queries a location with no bound sensor, in either view', async () => {
+    // The gate that started this file: a location absent from
+    // getLocationHASensors() must never appear in the readings useQueries
+    // array at all — not merely disabled — so it costs nothing whether its
+    // card/row is on screen or not. Most installs have far more storage
+    // locations than ones actually wired up to Home Assistant.
+    let unsensoredLocationRequestCount = 0;
+    vi.mocked(window.localStorage.getItem).mockImplementation((key: string) =>
+      key === COLUMN_CONFIG_KEY
+        ? JSON.stringify([{ id: 'temperature', label: 'Temperature', visible: true }])
+        : null
+    );
+    server.use(
+      http.get('/api/v1/inventory/spools', () =>
+        HttpResponse.json([mockSpool, mockSpoolAtUnsensoredLocation])
+      ),
+      http.get('/api/v1/location-ha-sensors/by-location/8/readings', () => {
+        unsensoredLocationRequestCount += 1;
+        return HttpResponse.json([]);
+      }),
+    );
+
+    const user = userEvent.setup();
+    render(<InventoryPageRouter />);
+
+    // Table view, temperature column visible: location 7 (has a sensor) is queried.
+    await waitFor(() => expect(screen.getByText('24.50 °C')).toBeInTheDocument());
+    expect(screen.getAllByText('eSun').length).toBeGreaterThan(0);
+    await new Promise((r) => setTimeout(r, 50));
+    expect(readingsRequestCount).toBe(1);
+    expect(unsensoredLocationRequestCount).toBe(0);
+
+    // Card view too — the footer for location 8's card must not query it either.
+    await user.click(screen.getByText('Cards'));
+    await waitFor(() => expect(screen.getAllByText('eSun').length).toBeGreaterThan(0));
+    await new Promise((r) => setTimeout(r, 50));
+    expect(unsensoredLocationRequestCount).toBe(0);
+  });
+});

+ 165 - 0
frontend/src/__tests__/pages/LocationSensorReadingsCrossPageCache.test.tsx

@@ -0,0 +1,165 @@
+/**
+ * InventoryPage, SpoolLocationFooter, and SettingsPage's sensor overview all
+ * read one location's live readings under the same query key now
+ * (['locationHaSensorReadings', locationId], no 'all'/'cardOnly' suffix).
+ * SettingsPage was missed when the other two were unified and kept its own
+ * 'all'-suffixed key — this pins that navigating between the two pages in
+ * the same session reuses the cache instead of firing a second request for
+ * a location that was just fetched.
+ */
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import { render, screen, waitFor, cleanup } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { BrowserRouter } from 'react-router-dom';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+import { AuthProvider } from '../../contexts/AuthContext';
+import { ThemeProvider } from '../../contexts/ThemeContext';
+import { ToastProvider } from '../../contexts/ToastContext';
+import InventoryPageRouter from '../../pages/InventoryPage';
+import { SettingsPage } from '../../pages/SettingsPage';
+
+const mockSpool = {
+  id: 1,
+  material: 'PLA',
+  subtype: null,
+  brand: 'Polymaker',
+  color_name: 'Red',
+  rgba: 'FF0000FF',
+  label_weight: 1000,
+  core_weight: 250,
+  weight_used: 100,
+  slicer_filament: null,
+  slicer_filament_name: null,
+  nozzle_temp_min: null,
+  nozzle_temp_max: null,
+  note: null,
+  added_full: null,
+  last_used: null,
+  encode_time: null,
+  tag_uid: null,
+  tray_uuid: null,
+  data_origin: null,
+  tag_type: null,
+  archived_at: null,
+  created_at: '2025-01-01T00:00:00Z',
+  updated_at: '2025-01-01T00:00:00Z',
+  k_profiles: [],
+  cost_per_kg: null,
+  last_scale_weight: null,
+  last_weighed_at: null,
+  location_id: 7,
+  storage_location: 'Drybox 1',
+};
+
+const mockSensor = {
+  id: 1,
+  location_id: 7,
+  name: 'Drybox 1 Temperature',
+  entity_id: 'sensor.drybox_1_temperature',
+  kind: 'numeric',
+  device_class: 'temperature',
+  unit: '°C',
+  alert_state: null,
+  alert_above: null,
+  alert_below: null,
+  notify_on_alert: false,
+  show_on_card: true,
+  sort_order: 0,
+  last_state: '24.5',
+  last_changed: '2025-01-01T00:00:00Z',
+  created_at: '2025-01-01T00:00:00Z',
+  updated_at: '2025-01-01T00:00:00Z',
+};
+
+const mockReading = {
+  id: 1,
+  name: 'Drybox 1 Temperature',
+  entity_id: 'sensor.drybox_1_temperature',
+  kind: 'numeric',
+  device_class: 'temperature',
+  unit: '°C',
+  state: '24.5',
+  value: 24.5,
+  alerting: false,
+  reachable: true,
+  alert_state: null,
+  alert_above: null,
+  alert_below: null,
+  last_changed: '2025-01-01T00:00:00Z',
+  show_on_card: true,
+};
+
+// A shared client, mimicking the app's real one: unlike the default test
+// client's gcTime: 0, unmounting a page must not evict the cache the next
+// page is meant to reuse.
+function SharedProviders({ children, client }: { children: React.ReactNode; client: QueryClient }) {
+  return (
+    <QueryClientProvider client={client}>
+      <BrowserRouter>
+        <AuthProvider>
+          <ThemeProvider>
+            <ToastProvider>{children}</ToastProvider>
+          </ThemeProvider>
+        </AuthProvider>
+      </BrowserRouter>
+    </QueryClientProvider>
+  );
+}
+
+describe('location sensor readings — shared cache across pages', () => {
+  let readingsRequestCount: number;
+  let client: QueryClient;
+
+  beforeEach(() => {
+    readingsRequestCount = 0;
+    // staleTime matches App.tsx's real QueryClient — the default of 0 would
+    // make every remount refetch regardless of whether the keys line up,
+    // which is not what production does and would make this test pass for
+    // the wrong reason.
+    client = new QueryClient({
+      defaultOptions: { queries: { retry: false, staleTime: 1000 * 60 }, mutations: { retry: false } },
+    });
+    server.use(
+      http.get('/api/v1/inventory/spools', () => HttpResponse.json([mockSpool])),
+      http.get('/api/v1/location-ha-sensors/', () => HttpResponse.json([mockSensor])),
+      http.get('/api/v1/inventory/locations', () =>
+        HttpResponse.json([{ id: 7, name: 'Drybox 1', identifier: null, spool_count: 1, created_at: '', updated_at: '' }])
+      ),
+      http.get('/api/v1/location-ha-sensors/by-location/7/readings', () => {
+        readingsRequestCount += 1;
+        return HttpResponse.json([mockReading]);
+      }),
+    );
+  });
+
+  it('does not refetch a location Inventory already loaded when Settings opens next', async () => {
+    const user = userEvent.setup();
+
+    render(
+      <SharedProviders client={client}>
+        <InventoryPageRouter />
+      </SharedProviders>
+    );
+    await user.click(await screen.findByText('Cards'));
+    await waitFor(() => expect(screen.getByText('24.50 °C')).toBeInTheDocument());
+    expect(readingsRequestCount).toBe(1);
+
+    cleanup();
+
+    render(
+      <SharedProviders client={client}>
+        <SettingsPage />
+      </SharedProviders>
+    );
+    await user.click(await screen.findByText('Sensors'));
+    await screen.findByText('sensor.drybox_1_temperature');
+
+    // Give a stray refetch a moment to land before asserting its absence —
+    // if the keys still mismatched, this is where the second request fires.
+    await new Promise((r) => setTimeout(r, 50));
+    expect(readingsRequestCount).toBe(1);
+  });
+});

+ 225 - 0
frontend/src/__tests__/pages/SettingsPage.test.tsx

@@ -1530,6 +1530,231 @@ describe('SettingsPage', () => {
   });
 });
 
+/**
+ * Location sensor cards on Settings -> Sensors read the live readings
+ * endpoint (reachable-aware) rather than the sensor row's last_state, so an
+ * entity Home Assistant has stopped reporting shows "Unavailable" instead of
+ * silently keeping its last colorized value on screen forever.
+ */
+describe('SettingsPage — location sensor reachability', () => {
+  beforeEach(() => {
+    window.history.replaceState({}, '', '/');
+  });
+
+  const locationSensor = {
+    id: 1,
+    location_id: 7,
+    name: 'Drybox 1 Temperature',
+    entity_id: 'sensor.drybox_1_temperature',
+    kind: 'numeric',
+    device_class: 'temperature',
+    unit: '°C',
+    alert_state: null,
+    alert_above: 30,
+    alert_below: 20,
+    notify_on_alert: false,
+    show_on_card: true,
+    sort_order: 0,
+    last_state: '65.0',
+    last_changed: null,
+    last_checked: null,
+    created_at: '',
+    updated_at: '',
+  };
+
+  it('shows "Unavailable" for an unreachable sensor instead of its stale last value', async () => {
+    server.use(
+      http.get('/api/v1/location-ha-sensors/', () => HttpResponse.json([locationSensor])),
+      http.get('/api/v1/location-ha-sensors/by-location/7/readings', () =>
+        HttpResponse.json([
+          {
+            id: 1,
+            name: 'Drybox 1 Temperature',
+            entity_id: 'sensor.drybox_1_temperature',
+            kind: 'numeric',
+            device_class: 'temperature',
+            unit: '°C',
+            state: null,
+            value: null,
+            alerting: false,
+            reachable: false,
+            alert_state: null,
+            alert_above: 30,
+            alert_below: 20,
+            last_changed: null,
+          },
+        ])
+      ),
+      http.get('/api/v1/inventory/locations', () =>
+        HttpResponse.json([{ id: 7, name: 'Drybox 1', identifier: null, spool_count: 0, created_at: '', updated_at: '' }])
+      )
+    );
+
+    const user = userEvent.setup();
+    render(<SettingsPage />);
+
+    await user.click(await screen.findByText('Sensors'));
+    await screen.findByText('sensor.drybox_1_temperature');
+
+    expect(await screen.findByText('Unavailable')).toBeInTheDocument();
+    expect(screen.queryByText('65.00 °C')).not.toBeInTheDocument();
+  });
+
+  it('shows the live value, not last_state, when the sensor is reachable', async () => {
+    server.use(
+      http.get('/api/v1/location-ha-sensors/', () => HttpResponse.json([locationSensor])),
+      http.get('/api/v1/location-ha-sensors/by-location/7/readings', () =>
+        HttpResponse.json([
+          {
+            id: 1,
+            name: 'Drybox 1 Temperature',
+            entity_id: 'sensor.drybox_1_temperature',
+            kind: 'numeric',
+            device_class: 'temperature',
+            unit: '°C',
+            state: '24.5',
+            value: 24.5,
+            alerting: false,
+            reachable: true,
+            alert_state: null,
+            alert_above: 30,
+            alert_below: 20,
+            last_changed: null,
+          },
+        ])
+      ),
+      http.get('/api/v1/inventory/locations', () =>
+        HttpResponse.json([{ id: 7, name: 'Drybox 1', identifier: null, spool_count: 0, created_at: '', updated_at: '' }])
+      )
+    );
+
+    const user = userEvent.setup();
+    render(<SettingsPage />);
+
+    await user.click(await screen.findByText('Sensors'));
+    await screen.findByText('sensor.drybox_1_temperature');
+
+    expect(await screen.findByText('24.50 °C')).toBeInTheDocument();
+    expect(screen.queryByText('Unavailable')).not.toBeInTheDocument();
+  });
+
+  it('shows the entity id in the overview row, with the display name as its hover title', async () => {
+    server.use(
+      http.get('/api/v1/location-ha-sensors/', () => HttpResponse.json([locationSensor])),
+      http.get('/api/v1/location-ha-sensors/by-location/7/readings', () => HttpResponse.json([])),
+      http.get('/api/v1/inventory/locations', () =>
+        HttpResponse.json([{ id: 7, name: 'Drybox 1', identifier: null, spool_count: 0, created_at: '', updated_at: '' }])
+      )
+    );
+
+    const user = userEvent.setup();
+    render(<SettingsPage />);
+
+    await user.click(await screen.findByText('Sensors'));
+
+    const entityIdText = await screen.findByText('sensor.drybox_1_temperature');
+    expect(entityIdText).toHaveAttribute('title', 'Drybox 1 Temperature');
+    expect(screen.queryByText('Drybox 1 Temperature')).not.toBeInTheDocument();
+  });
+
+  it('refreshes the sensor list even when a bulk delete partially fails', async () => {
+    let sensors = [
+      { ...locationSensor, id: 1, name: 'Drybox 1 Temperature' },
+      {
+        ...locationSensor,
+        id: 2,
+        name: 'Drybox 1 Humidity',
+        entity_id: 'sensor.drybox_1_humidity',
+        device_class: 'humidity',
+        unit: '%',
+      },
+    ];
+
+    server.use(
+      http.get('/api/v1/location-ha-sensors/', () => HttpResponse.json(sensors)),
+      http.get('/api/v1/location-ha-sensors/by-location/7/readings', () => HttpResponse.json([])),
+      http.get('/api/v1/inventory/locations', () =>
+        HttpResponse.json([{ id: 7, name: 'Drybox 1', identifier: null, spool_count: 0, created_at: '', updated_at: '' }])
+      ),
+      // The first delete succeeds and actually removes the row; the second
+      // fails, simulating a partial failure partway through the sequential
+      // delete loop.
+      http.delete('/api/v1/location-ha-sensors/1', () => {
+        sensors = sensors.filter((s) => s.id !== 1);
+        return HttpResponse.json({ message: 'Sensor removed' });
+      }),
+      http.delete('/api/v1/location-ha-sensors/2', () => new HttpResponse(null, { status: 500 }))
+    );
+
+    const user = userEvent.setup();
+    render(<SettingsPage />);
+
+    await user.click(await screen.findByText('Sensors'));
+    await screen.findByText('sensor.drybox_1_temperature');
+    await screen.findByText('sensor.drybox_1_humidity');
+
+    await user.click(screen.getByRole('button', { name: 'Delete' }));
+    await user.click(await screen.findByRole('button', { name: 'Confirm' }));
+
+    // The sensor that actually got deleted on the backend must not linger on
+    // screen just because the batch as a whole reported an error.
+    await waitFor(() => expect(screen.queryByText('sensor.drybox_1_temperature')).not.toBeInTheDocument());
+    expect(screen.getByText('sensor.drybox_1_humidity')).toBeInTheDocument();
+  });
+
+  it('orders location cards by location, not by the order their sensors were created', async () => {
+    // Sensor for location 8 ("Drybox 10") appears in the array before the
+    // sensor for location 7 ("Drybox 2") — a naive Map-insertion-order
+    // render would put "Drybox 10" first. Card order must follow the
+    // (naturally sorted) locations list instead.
+    const sensors = [
+      {
+        id: 1,
+        location_id: 8,
+        name: 'Drybox 10 Temperature',
+        entity_id: 'sensor.drybox_10_temperature',
+        device_class: 'temperature',
+        unit: '°C',
+      },
+      {
+        id: 2,
+        location_id: 7,
+        name: 'Drybox 2 Temperature',
+        entity_id: 'sensor.drybox_2_temperature',
+        device_class: 'temperature',
+        unit: '°C',
+      },
+    ];
+
+    server.use(
+      http.get('/api/v1/location-ha-sensors/', () => HttpResponse.json(sensors)),
+      http.get('/api/v1/location-ha-sensors/by-location/7/readings', () => HttpResponse.json([])),
+      http.get('/api/v1/location-ha-sensors/by-location/8/readings', () => HttpResponse.json([])),
+      http.get('/api/v1/inventory/locations', () =>
+        HttpResponse.json([
+          { id: 7, name: 'Drybox 2', identifier: null, spool_count: 0, created_at: '', updated_at: '' },
+          { id: 8, name: 'Drybox 10', identifier: null, spool_count: 0, created_at: '', updated_at: '' },
+        ])
+      )
+    );
+
+    const user = userEvent.setup();
+    const { container } = render(<SettingsPage />);
+
+    await user.click(await screen.findByText('Sensors'));
+    await screen.findByText('sensor.drybox_10_temperature');
+
+    const cardTitles = Array.from(container.querySelectorAll('.text-white.font-medium.truncate')).map(
+      (el) => el.textContent
+    );
+    const drybox2Index = cardTitles.indexOf('Drybox 2');
+    const drybox10Index = cardTitles.indexOf('Drybox 10');
+    expect(drybox2Index).toBeGreaterThanOrEqual(0);
+    expect(drybox10Index).toBeGreaterThanOrEqual(0);
+    expect(drybox2Index).toBeLessThan(drybox10Index);
+  });
+});
+
 /**
  * Sponsor banner on Settings -> General.
  *

+ 61 - 0
frontend/src/__tests__/utils/haSensorDisplay.test.ts

@@ -0,0 +1,61 @@
+/**
+ * describeHASensorReading is the single formatter shared by the printer row
+ * and both location-sensor display sites (InventoryPage, SettingsPage).
+ * Before this, all three carried their own near-identical copy, and the
+ * printer's copy (no decimals) had quietly drifted from the location copies
+ * (fixed to two decimals, a deliberate width-alignment choice). Locking both
+ * behaviors down here as the one place either could change.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { describeHASensorReading } from '../../utils/haSensorDisplay';
+
+const t = (key: string, opts?: Record<string, unknown>) =>
+  (opts?.defaultValue as string | undefined) ?? key;
+
+describe('describeHASensorReading', () => {
+  it('shows the raw value with no decimals option — the printer row behavior', () => {
+    const reading = { kind: 'numeric' as const, value: 23.4, unit: '°C', state: '23.4', reachable: true, device_class: 'temperature' };
+    expect(describeHASensorReading(reading, t)).toBe('23.4 °C');
+  });
+
+  it('pads to a fixed decimal count when asked — the location sensor behavior', () => {
+    const reading = { kind: 'numeric' as const, value: 23.4, unit: '°C', state: '23.4', reachable: true, device_class: 'temperature' };
+    expect(describeHASensorReading(reading, t, { decimals: 2 })).toBe('23.40 °C');
+  });
+
+  it('pads a whole-number value too, e.g. battery at 87%', () => {
+    const reading = { kind: 'numeric' as const, value: 87, unit: '%', state: '87', reachable: true, device_class: 'battery' };
+    expect(describeHASensorReading(reading, t, { decimals: 2 })).toBe('87.00 %');
+  });
+
+  it('omits the unit when the reading has none', () => {
+    const reading = { kind: 'numeric' as const, value: 23.4, unit: null, state: '23.4', reachable: true, device_class: 'temperature' };
+    expect(describeHASensorReading(reading, t)).toBe('23.4');
+  });
+
+  it('falls back to the raw state string when value is null', () => {
+    const reading = { kind: 'numeric' as const, value: null, unit: '°C', state: 'unknown', reachable: true, device_class: 'temperature' };
+    expect(describeHASensorReading(reading, t)).toBe('unknown');
+  });
+
+  it('shows unavailable text for an unreachable sensor, regardless of kind', () => {
+    const reading = { kind: 'numeric' as const, value: 23.4, unit: '°C', state: '23.4', reachable: false, device_class: 'temperature' };
+    expect(describeHASensorReading(reading, t)).toBe('haSensors.unavailable');
+  });
+
+  it('shows unavailable text when state is null even if marked reachable', () => {
+    const reading = { kind: 'numeric' as const, value: null, unit: '°C', state: null, reachable: true, device_class: 'temperature' };
+    expect(describeHASensorReading(reading, t)).toBe('haSensors.unavailable');
+  });
+
+  it('translates a binary reading through its device-class label', () => {
+    const reading = { kind: 'binary' as const, value: null, unit: null, state: 'on', reachable: true, device_class: 'door' };
+    expect(describeHASensorReading(reading, t)).toBe('open');
+  });
+
+  it('falls back to the raw on/off state for a binary reading with no known device class', () => {
+    const reading = { kind: 'binary' as const, value: null, unit: null, state: 'on', reachable: true, device_class: 'unmapped_class' };
+    expect(describeHASensorReading(reading, t)).toBe('on');
+  });
+});

+ 109 - 0
frontend/src/__tests__/utils/locationSensorAlertDefaults.test.ts

@@ -0,0 +1,109 @@
+/**
+ * The per-category alert defaults are server-backed (#2824 review round 4).
+ *
+ * They seed the alert rule written onto each sensor row when one is bound, so
+ * two admins binding sensors from different browsers must not seed different
+ * rules, and a backup/restore has to carry them. Only `showOnCard` stays in
+ * localStorage: show_on_card is decided per sensor and this is nothing more
+ * than the form's pre-selection.
+ */
+
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import {
+  defaultLocationSensorDefaults,
+  loadLocationSensorDefaults,
+  saveLocationSensorShowOnCardDefaults,
+  serializeLocationSensorAlertDefaults,
+} from '../../utils/locationSensorDefaults';
+
+beforeEach(() => {
+  vi.mocked(localStorage.getItem).mockReset();
+  vi.mocked(localStorage.setItem).mockReset();
+});
+
+describe('location sensor alert defaults — server-backed', () => {
+  it('falls back to the built-ins when the setting is empty', () => {
+    expect(loadLocationSensorDefaults('')).toEqual(defaultLocationSensorDefaults());
+    expect(loadLocationSensorDefaults(undefined)).toEqual(defaultLocationSensorDefaults());
+    expect(loadLocationSensorDefaults(null)).toEqual(defaultLocationSensorDefaults());
+  });
+
+  it('takes the alert fields from the server value', () => {
+    const json = JSON.stringify({
+      humidity: { alertAbove: '45', alertBelow: '15', notifyOnAlert: true },
+    });
+
+    const defaults = loadLocationSensorDefaults(json);
+
+    expect(defaults.humidity.alertAbove).toBe('45');
+    expect(defaults.humidity.alertBelow).toBe('15');
+    expect(defaults.humidity.notifyOnAlert).toBe(true);
+    // Categories absent from the setting keep their built-ins.
+    expect(defaults.temperature).toEqual(defaultLocationSensorDefaults().temperature);
+  });
+
+  it('round-trips through serialize', () => {
+    const original = defaultLocationSensorDefaults();
+    original.temperature.alertAbove = '35';
+    original.battery.notifyOnAlert = true;
+
+    const reloaded = loadLocationSensorDefaults(serializeLocationSensorAlertDefaults(original));
+
+    expect(reloaded.temperature.alertAbove).toBe('35');
+    expect(reloaded.battery.notifyOnAlert).toBe(true);
+  });
+
+  it('never puts showOnCard in the server value — it is per sensor, not per installation', () => {
+    const defaults = defaultLocationSensorDefaults();
+    defaults.humidity.showOnCard = false;
+
+    const parsed = JSON.parse(serializeLocationSensorAlertDefaults(defaults));
+
+    expect(parsed.humidity).not.toHaveProperty('showOnCard');
+    expect(Object.keys(parsed.humidity).sort()).toEqual(['alertAbove', 'alertBelow', 'notifyOnAlert']);
+  });
+
+  // setup.ts replaces localStorage with bare vi.fn() stubs that store nothing,
+  // so these drive it explicitly rather than round-tripping through it.
+  it('writes only the per-category booleans to localStorage', () => {
+    const defaults = defaultLocationSensorDefaults();
+    defaults.humidity.showOnCard = false;
+
+    saveLocationSensorShowOnCardDefaults(defaults);
+
+    expect(localStorage.setItem).toHaveBeenCalledWith(
+      'bambuddy-location-sensor-show-on-card-defaults',
+      JSON.stringify({ temperature: true, humidity: false, battery: true })
+    );
+  });
+
+  it('applies the stored showOnCard over the built-in, independent of the server value', () => {
+    vi.mocked(localStorage.getItem).mockReturnValue(JSON.stringify({ humidity: false }));
+
+    const reloaded = loadLocationSensorDefaults(
+      serializeLocationSensorAlertDefaults(defaultLocationSensorDefaults())
+    );
+
+    expect(reloaded.humidity.showOnCard).toBe(false);
+    expect(reloaded.temperature.showOnCard).toBe(true);
+  });
+
+  it('survives a corrupted setting instead of blocking the dialog', () => {
+    expect(loadLocationSensorDefaults('not json at all')).toEqual(defaultLocationSensorDefaults());
+  });
+
+  it('ignores unexpected keys and wrong types in the stored value', () => {
+    const json = JSON.stringify({
+      humidity: { alertAbove: 45, notifyOnAlert: 'yes', showOnCard: false, injected: 'x' },
+    });
+
+    const defaults = loadLocationSensorDefaults(json);
+
+    // Wrong types are rejected, built-ins survive.
+    expect(defaults.humidity.alertAbove).toBe(defaultLocationSensorDefaults().humidity.alertAbove);
+    expect(defaults.humidity.notifyOnAlert).toBe(false);
+    // showOnCard from the server value must not win over the local preference.
+    expect(defaults.humidity.showOnCard).toBe(true);
+    expect(defaults.humidity).not.toHaveProperty('injected');
+  });
+});

+ 68 - 0
frontend/src/__tests__/utils/locationSensorColorPrefsLive.test.ts

@@ -0,0 +1,68 @@
+/**
+ * useLocationSensorColorPrefs stays live across already-mounted callers.
+ *
+ * Regression: InventoryPage and every SpoolLocationFooter used to read these
+ * four colour preferences once, in a useState initializer — changing them in
+ * Settings did nothing to an already-open Inventory page until a reload. This
+ * hook re-reads on a save from anywhere, so InventoryPage and SettingsPage
+ * (both of which now consume it) see the same value at the same time without
+ * either page needing a reload.
+ */
+
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import {
+  useLocationSensorColorPrefs,
+  saveLocationSensorAlertAboveColor,
+  saveLocationSensorColorizeValues,
+} from '../../utils/locationSensorDefaults';
+
+describe('useLocationSensorColorPrefs', () => {
+  beforeEach(() => {
+    const store = new Map<string, string>();
+    vi.mocked(window.localStorage.getItem).mockReset();
+    vi.mocked(window.localStorage.setItem).mockReset();
+    vi.mocked(window.localStorage.getItem).mockImplementation((key: string) => store.get(key) ?? null);
+    vi.mocked(window.localStorage.setItem).mockImplementation((key: string, value: string) => {
+      store.set(key, value);
+    });
+  });
+
+  it('picks up a save from elsewhere without remounting', () => {
+    const { result } = renderHook(() => useLocationSensorColorPrefs());
+
+    expect(result.current.aboveColor).toBe('purple');
+
+    act(() => {
+      saveLocationSensorAlertAboveColor('blue');
+    });
+
+    expect(result.current.aboveColor).toBe('blue');
+  });
+
+  it('updates every mounted caller at once — the Inventory page and Settings both watching', () => {
+    const inventoryPage = renderHook(() => useLocationSensorColorPrefs());
+    const settingsPage = renderHook(() => useLocationSensorColorPrefs());
+
+    act(() => {
+      saveLocationSensorColorizeValues(false);
+    });
+
+    expect(inventoryPage.result.current.colorize).toBe(false);
+    expect(settingsPage.result.current.colorize).toBe(false);
+  });
+
+  it('removes its event listener on unmount', () => {
+    const addSpy = vi.spyOn(window, 'addEventListener');
+    const removeSpy = vi.spyOn(window, 'removeEventListener');
+
+    const { unmount } = renderHook(() => useLocationSensorColorPrefs());
+    const [eventName] = addSpy.mock.calls.find(([name]) => name.startsWith('bambuddy:'))!;
+
+    unmount();
+
+    expect(removeSpy).toHaveBeenCalledWith(eventName, expect.any(Function));
+    addSpy.mockRestore();
+    removeSpy.mockRestore();
+  });
+});

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

@@ -1438,6 +1438,12 @@ export interface AppSettings {
   obico_enabled_printers: string;
   // Inventory forecasting global lead time
   forecast_global_lead_time_days: number;
+  location_sensor_poll_interval: number;
+  // JSON map of sensor category → {alertAbove, alertBelow, notifyOnAlert},
+  // seeding new storage-location sensor bindings. Empty = built-in defaults.
+  // Server-backed so two admins seed the same alert rules and a restore
+  // brings them back; see utils/locationSensorDefaults.ts.
+  location_sensor_alert_defaults: string;
 }
 
 export type AppSettingsUpdate = Partial<AppSettings>;
@@ -2350,6 +2356,62 @@ export interface PrinterHASensorCreate {
 
 export type PrinterHASensorUpdate = Partial<Omit<PrinterHASensorCreate, 'printer_id'>>;
 
+export interface LocationHASensor {
+  id: number;
+  location_id: number;
+  name: string;
+  entity_id: string;
+  kind: 'binary' | 'numeric';
+  device_class: string | null;
+  unit: string | null;
+  alert_state: 'on' | 'off' | null;
+  alert_above: number | null;
+  alert_below: number | null;
+  notify_on_alert: boolean;
+  show_on_card: boolean;
+  sort_order: number;
+  last_state: string | null;
+  last_changed: string | null;
+  last_checked: string | null;
+  created_at: string;
+  updated_at: string;
+}
+
+export interface LocationHASensorReading {
+  id: number;
+  name: string;
+  entity_id: string;
+  kind: 'binary' | 'numeric';
+  device_class: string | null;
+  unit: string | null;
+  state: string | null;
+  value: number | null;
+  alerting: boolean;
+  reachable: boolean;
+  alert_state: string | null;
+  alert_above: number | null;
+  alert_below: number | null;
+  last_changed: string | null;
+  show_on_card: boolean;
+}
+
+export interface LocationHASensorCreate {
+  location_id: number;
+  name: string;
+  entity_id: string;
+  kind: 'binary' | 'numeric';
+  device_class?: string | null;
+  unit?: string | null;
+  alert_state?: 'on' | 'off' | null;
+  alert_above?: number | null;
+  alert_below?: number | null;
+  notify_on_alert?: boolean;
+  show_on_card?: boolean;
+  sort_order?: number;
+}
+
+export type LocationHASensorUpdate = Partial<Omit<LocationHASensorCreate, 'location_id'>>;
+
 // An entity offered by the binding picker.
 export interface HADisplayEntity {
   entity_id: string;
@@ -2831,6 +2893,7 @@ export interface NotificationProvider {
   // Bed cooled
   on_bed_cooled: boolean;
   on_ha_sensor_alert: boolean;
+  on_location_ha_sensor_alert: boolean;
   // First layer complete
   on_first_layer_complete: boolean;
   // Inventory stock alerts
@@ -2894,6 +2957,7 @@ export interface NotificationProviderCreate {
   // Bed cooled
   on_bed_cooled?: boolean;
   on_ha_sensor_alert?: boolean;
+  on_location_ha_sensor_alert?: boolean;
   // First layer complete
   on_first_layer_complete?: boolean;
   // Inventory stock alerts
@@ -2950,6 +3014,7 @@ export interface NotificationProviderUpdate {
   // Bed cooled
   on_bed_cooled?: boolean;
   on_ha_sensor_alert?: boolean;
+  on_location_ha_sensor_alert?: boolean;
   // First layer complete
   on_first_layer_complete?: boolean;
   // Inventory stock alerts
@@ -5737,6 +5802,23 @@ export const api = {
   deleteHASensor: (id: number) =>
     request<{ message: string }>(`/ha-sensors/${id}`, { method: 'DELETE' }),
 
+  getLocationHASensors: (locationId?: number) =>
+    request<LocationHASensor[]>(`/location-ha-sensors/${locationId ? `?location_id=${locationId}` : ''}`),
+  getLocationHASensorReadings: (locationId: number, showOnCard = true) =>
+    request<LocationHASensorReading[]>(
+      `/location-ha-sensors/by-location/${locationId}/readings?show_on_card=${showOnCard}`
+    ),
+  getBindableLocationHAEntities: (search?: string) => {
+    const params = search ? `?search=${encodeURIComponent(search)}` : '';
+    return request<HADisplayEntity[]>(`/location-ha-sensors/entities${params}`);
+  },
+  createLocationHASensor: (data: LocationHASensorCreate) =>
+    request<LocationHASensor>('/location-ha-sensors/', { method: 'POST', body: JSON.stringify(data) }),
+  updateLocationHASensor: (id: number, data: LocationHASensorUpdate) =>
+    request<LocationHASensor>(`/location-ha-sensors/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
+  deleteLocationHASensor: (id: number) =>
+    request<{ message: string }>(`/location-ha-sensors/${id}`, { method: 'DELETE' }),
+
   // REST smart plug
   testRESTConnection: (url: string, method: string = 'GET', headers?: string | null) =>
     request<{ success: boolean; error: string | null }>('/smart-plugs/rest/test-connection', {

+ 12 - 0
frontend/src/components/AddNotificationModal.tsx

@@ -47,6 +47,9 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
   const [onPlateClearRequired, setOnPlateClearRequired] = useState(provider?.on_plate_clear_required ?? false);
   const [onBedCooled, setOnBedCooled] = useState(provider?.on_bed_cooled ?? false);
   const [onHaSensorAlert, setOnHaSensorAlert] = useState(provider?.on_ha_sensor_alert ?? false);
+  const [onLocationHaSensorAlert, setOnLocationHaSensorAlert] = useState(
+    provider?.on_location_ha_sensor_alert ?? false
+  );
   const [onFirstLayerComplete, setOnFirstLayerComplete] = useState(provider?.on_first_layer_complete ?? false);
 
   // Provider-specific config (scalar fields only — event_priorities is split out
@@ -204,6 +207,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
       on_plate_clear_required: onPlateClearRequired,
       on_bed_cooled: onBedCooled,
       on_ha_sensor_alert: onHaSensorAlert,
+      on_location_ha_sensor_alert: onLocationHaSensorAlert,
       on_first_layer_complete: onFirstLayerComplete,
     };
 
@@ -653,6 +657,13 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
                   </div>
                   <Toggle checked={onHaSensorAlert} onChange={setOnHaSensorAlert} />
                 </div>
+                <div className="flex items-center justify-between col-span-2">
+                  <div>
+                    <span className="text-sm text-white">{t('notifications.locationHaSensorAlert')}</span>
+                    <span className="text-xs text-bambu-gray ml-1">{t('notifications.locationHaSensorAlertDescription')}</span>
+                  </div>
+                  <Toggle checked={onLocationHaSensorAlert} onChange={setOnLocationHaSensorAlert} />
+                </div>
                 <div className="flex items-center justify-between">
                   <span className="text-sm text-white">{t('notifications.error')}</span>
                   <Toggle checked={onPrinterError} onChange={setOnPrinterError} />
@@ -708,6 +719,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
               if (onPrinterOffline) enabledEvents.push({ key: 'on_printer_offline', label: t('notifications.offline') });
               if (onPrinterError) enabledEvents.push({ key: 'on_printer_error', label: t('notifications.error') });
               if (onHaSensorAlert) enabledEvents.push({ key: 'on_ha_sensor_alert', label: t('notifications.haSensorAlert') });
+              if (onLocationHaSensorAlert) enabledEvents.push({ key: 'on_location_ha_sensor_alert', label: t('notifications.locationHaSensorAlert') });
               if (onAiFailureDetection) enabledEvents.push({ key: 'on_ai_failure_detection', label: t('notifications.aiFailureDetection') });
               if (onFilamentLow) enabledEvents.push({ key: 'on_filament_low', label: t('notifications.lowFilament') });
               if (onMaintenanceDue) enabledEvents.push({ key: 'on_maintenance_due', label: t('notifications.maintenance') });

+ 2 - 20
frontend/src/components/HASensorModal.tsx

@@ -6,6 +6,7 @@ import { api } from '../api/client';
 import type { HADisplayEntity, Printer, PrinterHASensor } from '../api/client';
 import { Button } from './Button';
 import { useToast } from '../contexts/ToastContext';
+import { HA_SENSOR_BINARY_LABELS } from '../utils/haSensorDisplay';
 
 /**
  * Bind a Home Assistant entity to a printer, or edit an existing binding
@@ -22,25 +23,6 @@ interface Props {
   onClose: () => void;
 }
 
-// The alert wording follows the device class, so a door offers "Open" rather
-// than "On". Shared with PrinterHASensorRow's rendering of the same classes.
-const ALERT_LABEL_KEYS: Record<string, { on: string; off: string }> = {
-  door: { on: 'open', off: 'closed' },
-  garage_door: { on: 'open', off: 'closed' },
-  window: { on: 'open', off: 'closed' },
-  opening: { on: 'open', off: 'closed' },
-  lock: { on: 'unlocked', off: 'locked' },
-  motion: { on: 'detected', off: 'clear' },
-  occupancy: { on: 'detected', off: 'clear' },
-  presence: { on: 'detected', off: 'clear' },
-  smoke: { on: 'detected', off: 'clear' },
-  gas: { on: 'detected', off: 'clear' },
-  moisture: { on: 'wet', off: 'dry' },
-  problem: { on: 'problem', off: 'ok' },
-  safety: { on: 'problem', off: 'ok' },
-  running: { on: 'running', off: 'stopped' },
-};
-
 export function HASensorModal({ sensor, printers, onClose }: Props) {
   const { t } = useTranslation();
   const queryClient = useQueryClient();
@@ -172,7 +154,7 @@ export function HASensorModal({ sensor, printers, onClose }: Props) {
     saveMutation.mutate();
   };
 
-  const alertLabels = ALERT_LABEL_KEYS[deviceClass ?? ''];
+  const alertLabels = HA_SENSOR_BINARY_LABELS[deviceClass ?? ''];
   const stateLabel = (which: 'on' | 'off') => {
     const key = alertLabels?.[which] ?? which;
     return t(`haSensors.states.${key}`, { defaultValue: key });

+ 670 - 0
frontend/src/components/LocationHASensorModal.tsx

@@ -0,0 +1,670 @@
+import { useEffect, useMemo, useRef, useState } from 'react';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { Gauge, Loader2, Plus, Save, Search, X } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { api } from '../api/client';
+import type { HADisplayEntity, LocationHASensor, StorageLocation } from '../api/client';
+import { Button } from './Button';
+import { ConfirmModal } from './ConfirmModal';
+import { LocationsModal } from './LocationsModal';
+import { useToast } from '../contexts/ToastContext';
+import { loadLocationSensorDefaults } from '../utils/locationSensorDefaults';
+import { HA_SENSOR_BINARY_LABELS } from '../utils/haSensorDisplay';
+
+interface Props {
+  sensor?: LocationHASensor | null;
+  locations: StorageLocation[];
+  onClose: () => void;
+}
+
+type SensorCategory = 'temperature' | 'humidity' | 'battery';
+// Doubles as the picker's filter (see `entities` below): an entity with no
+// category cannot be bound to a location at all.
+//
+// "moisture" is deliberately absent. It is Home Assistant's binary wet/dry
+// class, not a humidity percentage, and mapping it here made it a humidity
+// sensor everywhere downstream — it landed in the percent-formatted humidity
+// column rendering "wet", blocked a real humidity sensor on the same location
+// via the one-per-category rule, and could never take the seeded thresholds
+// because the schema rejects alert_above/alert_below for kind="binary".
+// A storage location wants a hygrometer, not a leak detector.
+function categoryFor(deviceClass: string | null): SensorCategory | null {
+  if (deviceClass === 'temperature') return 'temperature';
+  if (deviceClass === 'humidity') return 'humidity';
+  if (deviceClass === 'battery') return 'battery';
+  return null;
+}
+
+const CATEGORY_SUFFIXES: Record<SensorCategory, string> = {
+  temperature: 'temperature',
+  humidity: 'humidity',
+  battery: 'battery',
+};
+
+function findSiblingEntities(
+  entityId: string,
+  category: SensorCategory,
+  candidates: HADisplayEntity[]
+): HADisplayEntity[] {
+  const suffix = CATEGORY_SUFFIXES[category];
+  const lower = entityId.toLowerCase();
+  if (!lower.endsWith(suffix)) return [];
+  const prefix = entityId.slice(0, entityId.length - suffix.length);
+  const siblings: HADisplayEntity[] = [];
+  (Object.keys(CATEGORY_SUFFIXES) as SensorCategory[]).forEach((otherCategory) => {
+    if (otherCategory === category) return;
+    const candidateId = `${prefix}${CATEGORY_SUFFIXES[otherCategory]}`.toLowerCase();
+    const match = candidates.find((c) => c.entity_id.toLowerCase() === candidateId);
+    if (match) siblings.push(match);
+  });
+  return siblings;
+}
+
+export function LocationHASensorModal({ sensor, locations, onClose }: Props) {
+  const { t } = useTranslation();
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+  const isEditing = !!sensor;
+
+  const [locationId, setLocationId] = useState<number | ''>(sensor?.location_id ?? locations[0]?.id ?? '');
+  const [entityId, setEntityId] = useState(sensor?.entity_id ?? '');
+  const [kind, setKind] = useState<'binary' | 'numeric'>(sensor?.kind ?? 'numeric');
+  const [deviceClass, setDeviceClass] = useState<string | null>(sensor?.device_class ?? null);
+  const [unit, setUnit] = useState<string | null>(sensor?.unit ?? null);
+  const [name, setName] = useState(sensor?.name ?? '');
+  // Tracks the last name we auto-filled (or the initial saved name), so
+  // switching to a different entity can follow along with the new friendly
+  // name — but only while the field still holds what we put there. A name
+  // the user typed themselves is never overwritten by an entity change.
+  const autoFilledNameRef = useRef(sensor?.name ?? '');
+  const [alertState, setAlertState] = useState<'on' | 'off' | ''>(sensor?.alert_state ?? '');
+  const [alertAbove, setAlertAbove] = useState(sensor?.alert_above?.toString() ?? '');
+  const [alertBelow, setAlertBelow] = useState(sensor?.alert_below?.toString() ?? '');
+  const [notifyOnAlert, setNotifyOnAlert] = useState(sensor?.notify_on_alert ?? false);
+  const [showOnCard, setShowOnCard] = useState(sensor?.show_on_card ?? true);
+  const [search, setSearch] = useState('');
+  const [error, setError] = useState<string | null>(null);
+  const [showAddLocationModal, setShowAddLocationModal] = useState(false);
+
+  const [showOverwriteConfirm, setShowOverwriteConfirm] = useState(false);
+  const [overwriteTarget, setOverwriteTarget] = useState<LocationHASensor | null>(null);
+
+  const [showAutoAddConfirm, setShowAutoAddConfirm] = useState(false);
+  const [autoAddCandidates, setAutoAddCandidates] = useState<HADisplayEntity[]>([]);
+  const [autoAddSelected, setAutoAddSelected] = useState<Record<string, boolean>>({});
+
+  const { data: settings } = useQuery({
+    queryKey: ['settings'],
+    queryFn: api.getSettings,
+  });
+  const haConfigured = !!(settings?.ha_enabled && settings?.ha_url && settings?.ha_token);
+
+  const { data: rawEntities, isLoading: entitiesLoading, error: entitiesError } = useQuery({
+    queryKey: ['bindableLocationHAEntities'],
+    queryFn: () => api.getBindableLocationHAEntities(),
+    enabled: haConfigured,
+  });
+
+  const entities = useMemo(
+    () => (rawEntities ?? []).filter((e) => categoryFor(e.device_class) !== null),
+    [rawEntities]
+  );
+
+  const { data: allLocationSensors } = useQuery({
+    queryKey: ['locationHaSensors'],
+    queryFn: () => api.getLocationHASensors(),
+  });
+
+  const selected = entities.find((e) => e.entity_id === entityId);
+
+  const filtered = useMemo(() => {
+    const needle = search.trim().toLowerCase();
+    const all = entities ?? [];
+    const matches = needle
+      ? all.filter((e) => e.entity_id.toLowerCase().includes(needle) || e.friendly_name.toLowerCase().includes(needle))
+      : all;
+    if (!selected || matches[0]?.entity_id === selected.entity_id) return matches;
+    return [selected, ...matches.filter((e) => e.entity_id !== selected.entity_id)];
+  }, [entities, search, selected]);
+
+  const selectEntity = (entity: HADisplayEntity) => {
+    setEntityId(entity.entity_id);
+    setDeviceClass(entity.device_class);
+    // Sliced like the name below: the unit is snapshotted from Home Assistant,
+    // not typed by the user, so an oversized one must not come back as a 422
+    // on a field the form never showed them. The column is String(16).
+    setUnit(entity.unit_of_measurement?.slice(0, 16) ?? null);
+    const nextKind = entity.domain === 'binary_sensor' ? 'binary' : 'numeric';
+    setKind(nextKind);
+    if (nextKind === 'numeric') setAlertState('');
+    else {
+      setAlertAbove('');
+      setAlertBelow('');
+    }
+    // Sliced to the column width: Home Assistant friendly names have no length
+    // limit, and a long one would come back as a Pydantic error on a field the
+    // user did not type into. Follows the entity picker as long as the name
+    // still matches what we last auto-filled — a name the user typed
+    // themselves is left alone even when they pick a different entity.
+    if (name === autoFilledNameRef.current) {
+      const nextName = entity.friendly_name.slice(0, 100);
+      setName(nextName);
+      autoFilledNameRef.current = nextName;
+    }
+
+    if (!isEditing) {
+      const category = categoryFor(entity.device_class);
+      if (category) {
+        const defaults = loadLocationSensorDefaults(settings?.location_sensor_alert_defaults)[category];
+        if (nextKind === 'numeric') {
+          setAlertAbove(category === 'battery' ? '' : defaults.alertAbove);
+          setAlertBelow(defaults.alertBelow);
+        }
+        setNotifyOnAlert(defaults.notifyOnAlert);
+        setShowOnCard(defaults.showOnCard);
+      }
+    }
+  };
+
+  const invalidate = () => {
+    queryClient.invalidateQueries({ queryKey: ['locationHaSensors'] });
+    queryClient.invalidateQueries({ queryKey: ['locationHaSensorReadings'] });
+  };
+
+  const buildPrimaryPayload = () => ({
+    name: name.trim(),
+    entity_id: entityId,
+    kind,
+    device_class: deviceClass,
+    unit,
+    alert_state: kind === 'binary' && alertState ? alertState : null,
+    alert_above:
+      kind === 'numeric' && categoryFor(deviceClass) !== 'battery' && alertAbove !== '' ? Number(alertAbove) : null,
+    alert_below: kind === 'numeric' && alertBelow !== '' ? Number(alertBelow) : null,
+    notify_on_alert: notifyOnAlert,
+    show_on_card: showOnCard,
+  });
+
+  const saveMutation = useMutation({
+    mutationFn: () => {
+      const payload = buildPrimaryPayload();
+      return isEditing
+        ? api.updateLocationHASensor(sensor.id, payload)
+        : api.createLocationHASensor({ ...payload, location_id: Number(locationId) });
+    },
+    onSuccess: () => {
+      invalidate();
+      showToast(isEditing ? t('locationHaSensors.toast.updated') : t('locationHaSensors.toast.created'), 'success');
+      onClose();
+    },
+    onError: (err: Error) => setError(err.message),
+  });
+
+  const autoAddMutation = useMutation({
+    mutationFn: async () => {
+      const targetLocationId = Number(locationId);
+      await api.createLocationHASensor({ ...buildPrimaryPayload(), location_id: targetLocationId });
+      const defaults = loadLocationSensorDefaults(settings?.location_sensor_alert_defaults);
+      const chosen = autoAddCandidates.filter((entity) => autoAddSelected[entity.entity_id]);
+      for (const entity of chosen) {
+        const categoryDefaults = categoryFor(entity.device_class);
+        const d = categoryDefaults ? defaults[categoryDefaults] : null;
+        await api.createLocationHASensor({
+          name: entity.friendly_name.slice(0, 100),
+          entity_id: entity.entity_id,
+          kind: entity.domain === 'binary_sensor' ? 'binary' : 'numeric',
+          device_class: entity.device_class,
+          unit: entity.unit_of_measurement?.slice(0, 16) ?? null,
+          alert_state: null,
+          alert_above: categoryDefaults !== 'battery' && d && d.alertAbove !== '' ? Number(d.alertAbove) : null,
+          alert_below: d && d.alertBelow !== '' ? Number(d.alertBelow) : null,
+          notify_on_alert: d?.notifyOnAlert ?? false,
+          show_on_card: d?.showOnCard ?? showOnCard,
+          location_id: targetLocationId,
+        });
+      }
+      return chosen;
+    },
+    onSuccess: (chosen) => {
+      invalidate();
+      setShowAutoAddConfirm(false);
+      if (chosen.length > 0) {
+        showToast(t('locationHaSensors.autoAdd.added', { names: chosen.map((e) => e.entity_id).join(', ') }), 'success');
+      } else {
+        showToast(t('locationHaSensors.toast.created'), 'success');
+      }
+      onClose();
+    },
+    onError: (err: Error) => {
+      invalidate();
+      setShowAutoAddConfirm(false);
+      setError(err.message);
+    },
+  });
+
+  const deleteMutation = useMutation({
+    mutationFn: () => api.deleteLocationHASensor(sensor!.id),
+    onSuccess: () => {
+      invalidate();
+      showToast(t('locationHaSensors.toast.deleted'), 'success');
+      onClose();
+    },
+    onError: (err: Error) => setError(err.message),
+  });
+
+  const overwriteMutation = useMutation({
+    // PATCH the existing row onto the new entity instead of deleting it and
+    // creating a replacement: a delete-then-create left a window where, if
+    // the create failed, the old binding was already gone and nothing had
+    // taken its place. A single PATCH either lands or leaves the original
+    // binding untouched.
+    mutationFn: () => api.updateLocationHASensor(overwriteTarget!.id, buildPrimaryPayload()),
+    onSuccess: () => {
+      invalidate();
+      setShowOverwriteConfirm(false);
+      setOverwriteTarget(null);
+      showToast(t('locationHaSensors.toast.updated'), 'success');
+      onClose();
+    },
+    onError: (err: Error) => {
+      setShowOverwriteConfirm(false);
+      setOverwriteTarget(null);
+      setError(err.message);
+    },
+  });
+
+  const hasAlertCondition = kind === 'binary' ? alertState !== '' : alertAbove !== '' || alertBelow !== '';
+
+  const handleSubmit = (e: React.FormEvent) => {
+    e.preventDefault();
+    setError(null);
+    if (!entityId) return setError(t('haSensors.error.pickEntity'));
+    if (!name.trim()) return setError(t('haSensors.error.nameRequired'));
+    if (locationId === '') return setError(t('locationHaSensors.error.locationRequired'));
+    if (notifyOnAlert && !hasAlertCondition) {
+      return setError(t('haSensors.error.alertRequired'));
+    }
+
+    if (!isEditing) {
+      const locationSensors = (allLocationSensors ?? []).filter((s) => s.location_id === Number(locationId));
+      const category = categoryFor(deviceClass);
+
+      if (category) {
+        const conflicting = locationSensors.find((s) => categoryFor(s.device_class) === category);
+        if (conflicting) {
+          setOverwriteTarget(conflicting);
+          setShowOverwriteConfirm(true);
+          return;
+        }
+      }
+
+      if (locationSensors.length === 0 && category) {
+        const siblings = findSiblingEntities(entityId, category, entities);
+        if (siblings.length > 0) {
+          setAutoAddCandidates(siblings);
+          setAutoAddSelected(Object.fromEntries(siblings.map((s) => [s.entity_id, true])));
+          setShowAutoAddConfirm(true);
+          return;
+        }
+        showToast(t('locationHaSensors.autoAdd.noneFound'), 'info');
+      }
+    }
+
+    saveMutation.mutate();
+  };
+
+  const alertLabels = HA_SENSOR_BINARY_LABELS[deviceClass ?? ''];
+  const stateLabel = (which: 'on' | 'off') => {
+    const key = alertLabels?.[which] ?? which;
+    return t(`haSensors.states.${key}`, { defaultValue: key });
+  };
+
+  const isPending =
+    saveMutation.isPending || deleteMutation.isPending || overwriteMutation.isPending || autoAddMutation.isPending;
+  const currentLocation = locations.find((l) => l.id === Number(locationId));
+
+  // Escape closes the modal, but not while a mutation is mid-flight — a
+  // stray keypress landing between the overwrite PATCH's dispatch and its
+  // response must not drop the user out with an orphaned request.
+  useEffect(() => {
+    const onKey = (e: KeyboardEvent) => {
+      if (e.key === 'Escape' && !isPending) onClose();
+    };
+    window.addEventListener('keydown', onKey);
+    return () => window.removeEventListener('keydown', onKey);
+  }, [onClose, isPending]);
+
+  return (
+    <>
+    <div
+      className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
+      onClick={() => {
+        if (!isPending) onClose();
+      }}
+    >
+      <div
+        className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-lg max-h-[90vh] overflow-y-auto"
+        onClick={(e) => e.stopPropagation()}
+      >
+        <div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
+          <div className="flex items-center gap-3">
+            <div className="p-2 rounded-full bg-bambu-green/20 text-bambu-green">
+              <Gauge className="w-5 h-5" />
+            </div>
+            <h2 className="text-lg font-semibold text-white">
+              {isEditing ? t('locationHaSensors.editTitle') : t('locationHaSensors.addTitle')}
+            </h2>
+          </div>
+          <button
+            onClick={onClose}
+            disabled={isPending}
+            className="text-bambu-gray hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+          >
+            <X className="w-5 h-5" />
+          </button>
+        </div>
+
+        <form onSubmit={handleSubmit} className="p-6 space-y-4">
+          {error && (
+            <div className="p-3 bg-red-100 dark:bg-red-500/20 border border-red-300 dark:border-red-500/50 rounded-lg text-sm text-red-700 dark:text-red-400">
+              {error}
+            </div>
+          )}
+
+          <div>
+            <label className="block text-sm text-bambu-gray mb-1">{t('locationHaSensors.location')}</label>
+            {isEditing ? (
+              <div className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white">
+                {locations.find((l) => l.id === sensor.location_id)?.name ?? t('locationHaSensors.unknownLocation')}
+              </div>
+            ) : (
+              <div className="flex items-center gap-2">
+                <select
+                  value={locationId}
+                  onChange={(e) => setLocationId(e.target.value === '' ? '' : Number(e.target.value))}
+                  className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white"
+                >
+                  {locations.map((l) => (
+                    <option key={l.id} value={l.id}>
+                      {l.name}
+                    </option>
+                  ))}
+                </select>
+                <button
+                  type="button"
+                  onClick={() => setShowAddLocationModal(true)}
+                  className="p-2 rounded-lg bg-bambu-dark-tertiary hover:bg-bambu-gray-dark text-white transition-colors shrink-0"
+                  title={t('locations.add')}
+                  aria-label={t('locations.add')}
+                >
+                  <Plus className="w-4 h-4" />
+                </button>
+              </div>
+            )}
+          </div>
+
+          {!haConfigured && (
+            <div className="p-3 bg-yellow-100 dark:bg-yellow-500/20 border border-yellow-400 dark:border-yellow-500/50 rounded-lg text-sm text-yellow-700 dark:text-yellow-400">
+              {t('smartPlugs.haNotConfigured')}{' '}
+              <span className="font-medium">{t('smartPlugs.haSettingsPath')}</span>
+            </div>
+          )}
+
+          <div>
+            <label className={`block text-sm text-bambu-gray mb-1 ${haConfigured ? '' : 'opacity-50'}`}>
+              {t('haSensors.entity')}
+            </label>
+            {entitiesError && (
+              <div className="p-3 mb-2 bg-red-100 dark:bg-red-500/20 rounded-lg text-sm text-red-700 dark:text-red-400">
+                {(entitiesError as Error).message}
+              </div>
+            )}
+            <div className="relative mb-2">
+              <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
+              <input
+                type="text"
+                value={search}
+                onChange={(e) => setSearch(e.target.value)}
+                placeholder={t('haSensors.searchPlaceholder')}
+                disabled={!haConfigured}
+                className="w-full pl-9 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white disabled:opacity-50 disabled:cursor-not-allowed"
+              />
+            </div>
+            <div
+              className={`max-h-44 overflow-y-auto rounded-lg border border-bambu-dark-tertiary ${
+                haConfigured ? '' : 'opacity-50'
+              }`}
+            >
+              {entityId && !selected && (
+                <div className="px-3 py-2 text-sm bg-bambu-green/10 text-bambu-green border-b border-bambu-dark-tertiary">
+                  {t('locationHaSensors.currentlyBound', { entity: entityId })}
+                </div>
+              )}
+              {!haConfigured && <div className="p-3 text-sm text-bambu-gray">{t('haSensors.noEntities')}</div>}
+              {haConfigured && entitiesLoading && (
+                <div className="flex items-center gap-2 p-3 text-sm text-bambu-gray">
+                  <Loader2 className="w-4 h-4 animate-spin" />
+                  {t('common.loading')}
+                </div>
+              )}
+              {haConfigured && !entitiesLoading && filtered.length === 0 && (
+                <div className="p-3 text-sm text-bambu-gray">{t('haSensors.noEntities')}</div>
+              )}
+              {haConfigured &&
+                !entitiesLoading &&
+                filtered.map((entity) => (
+                  <button
+                    key={entity.entity_id}
+                    type="button"
+                    onClick={() => selectEntity(entity)}
+                    className={`w-full text-left px-3 py-2 text-sm transition-colors ${
+                      entity.entity_id === entityId
+                        ? 'bg-bambu-green/20 text-bambu-green'
+                        : 'text-white hover:bg-bambu-dark'
+                    }`}
+                  >
+                    <div className="font-medium">{entity.friendly_name}</div>
+                    <div className="text-xs text-bambu-gray">
+                      {entity.entity_id}
+                      {entity.state !== null && ` — ${entity.state}`}
+                      {entity.unit_of_measurement ? ` ${entity.unit_of_measurement}` : ''}
+                    </div>
+                  </button>
+                ))}
+            </div>
+          </div>
+
+          <div>
+            <label className="block text-sm text-bambu-gray mb-1" htmlFor="location-ha-sensor-name">
+              {t('haSensors.name')}
+            </label>
+            <input
+              id="location-ha-sensor-name"
+              type="text"
+              value={name}
+              onChange={(e) => setName(e.target.value)}
+              className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white"
+            />
+          </div>
+
+          {entityId && (
+            <div>
+              <label className="block text-sm text-bambu-gray mb-1">{t('haSensors.alertWhen')}</label>
+              {kind === 'binary' ? (
+                <select
+                  value={alertState}
+                  onChange={(e) => setAlertState(e.target.value as 'on' | 'off' | '')}
+                  className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white"
+                >
+                  <option value="">{t('haSensors.alertNever')}</option>
+                  <option value="on">{stateLabel('on')}</option>
+                  <option value="off">{stateLabel('off')}</option>
+                </select>
+              ) : categoryFor(deviceClass) === 'battery' ? (
+                <div>
+                  <span className="block text-xs text-bambu-gray mb-1">
+                    {t('haSensors.alertBelow')} {unit ?? ''}
+                  </span>
+                  <input
+                    type="number"
+                    step="any"
+                    value={alertBelow}
+                    onChange={(e) => setAlertBelow(e.target.value)}
+                    className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white"
+                  />
+                </div>
+              ) : (
+                <div className="grid grid-cols-2 gap-3">
+                  <div>
+                    <span className="block text-xs text-bambu-gray mb-1">
+                      {t('haSensors.alertAbove')} {unit ?? ''}
+                    </span>
+                    <input
+                      type="number"
+                      step="any"
+                      value={alertAbove}
+                      onChange={(e) => setAlertAbove(e.target.value)}
+                      className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white"
+                    />
+                  </div>
+                  <div>
+                    <span className="block text-xs text-bambu-gray mb-1">
+                      {t('haSensors.alertBelow')} {unit ?? ''}
+                    </span>
+                    <input
+                      type="number"
+                      step="any"
+                      value={alertBelow}
+                      onChange={(e) => setAlertBelow(e.target.value)}
+                      className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white"
+                    />
+                  </div>
+                </div>
+              )}
+              <p className="mt-1 text-xs text-bambu-gray">{t('haSensors.alertHint')}</p>
+            </div>
+          )}
+
+          <label className="flex items-center gap-3 cursor-pointer">
+            <input
+              type="checkbox"
+              checked={showOnCard}
+              onChange={(e) => setShowOnCard(e.target.checked)}
+              className="w-4 h-4"
+            />
+            <span className="text-sm text-white">{t('locationHaSensors.showOnCard')}</span>
+          </label>
+
+          <label className="flex items-center gap-3 cursor-pointer">
+            <input
+              type="checkbox"
+              checked={notifyOnAlert}
+              onChange={(e) => setNotifyOnAlert(e.target.checked)}
+              disabled={!hasAlertCondition}
+              className="w-4 h-4"
+            />
+            <span className={`text-sm ${hasAlertCondition ? 'text-white' : 'text-bambu-gray'}`}>
+              {t('haSensors.notifyOnAlert')}
+            </span>
+          </label>
+
+          <div className="flex items-center justify-between pt-2">
+            {isEditing ? (
+              <Button type="button" variant="danger" onClick={() => deleteMutation.mutate()} disabled={isPending}>
+                {t('common.delete')}
+              </Button>
+            ) : (
+              <span />
+            )}
+            <div className="flex items-center gap-2">
+              <Button type="button" variant="secondary" onClick={onClose} disabled={isPending}>
+                {t('common.cancel')}
+              </Button>
+              <Button type="submit" disabled={isPending || (!haConfigured && !isEditing)}>
+                {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
+                {t('common.save')}
+              </Button>
+            </div>
+          </div>
+        </form>
+      </div>
+    </div>
+    {showOverwriteConfirm && overwriteTarget && (
+      <ConfirmModal
+        title={t('locationHaSensors.overwriteConfirm.title')}
+        message={t(
+          `locationHaSensors.overwriteConfirm.message${
+            categoryFor(overwriteTarget.device_class) === 'humidity'
+              ? 'Humidity'
+              : categoryFor(overwriteTarget.device_class) === 'battery'
+                ? 'Battery'
+                : 'Temperature'
+          }`,
+          {
+            location: currentLocation?.name ?? '',
+            name: overwriteTarget.name,
+          }
+        )}
+        variant="warning"
+        overlayZIndex="z-[110]"
+        isLoading={overwriteMutation.isPending}
+        onConfirm={() => overwriteMutation.mutate()}
+        onCancel={() => {
+          setShowOverwriteConfirm(false);
+          setOverwriteTarget(null);
+        }}
+      />
+    )}
+    {showAutoAddConfirm && autoAddCandidates.length > 0 && (
+      <ConfirmModal
+        title={t('locationHaSensors.autoAdd.confirmTitle')}
+        message={t('locationHaSensors.autoAdd.confirmMessage')}
+        overlayZIndex="z-[110]"
+        isLoading={autoAddMutation.isPending}
+        confirmDisabled={!autoAddCandidates.some((e) => autoAddSelected[e.entity_id])}
+        onConfirm={() => autoAddMutation.mutate()}
+        // "Cancel" here still saves the sensor the user picked — it only
+        // skips the siblings — so the button needs to say what it does
+        // rather than implying the whole thing is being abandoned.
+        cancelText={t('locationHaSensors.autoAdd.onlyThisOne')}
+        onCancel={() => {
+          setShowAutoAddConfirm(false);
+          setAutoAddCandidates([]);
+          setAutoAddSelected({});
+          saveMutation.mutate();
+        }}
+      >
+        <div className="space-y-2">
+          {autoAddCandidates.map((entity) => (
+            <label key={entity.entity_id} className="flex items-center gap-3 cursor-pointer">
+              <input
+                type="checkbox"
+                checked={autoAddSelected[entity.entity_id] ?? true}
+                onChange={(e) =>
+                  setAutoAddSelected((prev) => ({ ...prev, [entity.entity_id]: e.target.checked }))
+                }
+                className="w-4 h-4"
+              />
+              <span className="text-sm text-white">
+                {entity.friendly_name}
+                <span className="text-bambu-gray"> — {entity.entity_id}</span>
+              </span>
+            </label>
+          ))}
+        </div>
+      </ConfirmModal>
+    )}
+    {showAddLocationModal && (
+      <LocationsModal
+        open={showAddLocationModal}
+        onClose={() => setShowAddLocationModal(false)}
+        onPickLocation={(id) => {
+          setLocationId(id);
+          setShowAddLocationModal(false);
+        }}
+        startCreating
+      />
+    )}
+    </>
+  );
+}

+ 452 - 0
frontend/src/components/LocationSensorOptionsModal.tsx

@@ -0,0 +1,452 @@
+import { useEffect, useRef, useState } from 'react';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { Battery, Droplets, RotateCcw, Save, Settings2, Thermometer, X } from 'lucide-react';
+import { api } from '../api/client';
+import type { AppSettingsUpdate } from '../api/client';
+import { Button } from './Button';
+import { ConfirmModal } from './ConfirmModal';
+import { useToast } from '../contexts/ToastContext';
+import {
+  LOCATION_SENSOR_ALERT_COLORS,
+  loadLocationSensorAlertAboveColor,
+  loadLocationSensorAlertBelowColor,
+  loadLocationSensorAlertOptimalColor,
+  loadLocationSensorColorizeValues,
+  loadLocationSensorDefaults,
+  saveLocationSensorAlertAboveColor,
+  saveLocationSensorAlertBelowColor,
+  saveLocationSensorAlertOptimalColor,
+  saveLocationSensorColorizeValues,
+  saveLocationSensorShowOnCardDefaults,
+  serializeLocationSensorAlertDefaults,
+  type LocationSensorAlertColor,
+  type LocationSensorCategory,
+  type LocationSensorCategoryDefaults,
+  type LocationSensorDefaults,
+} from '../utils/locationSensorDefaults';
+
+interface Props {
+  onClose: () => void;
+}
+
+const MIN_POLL_INTERVAL = 60;
+const DEFAULT_POLL_INTERVAL = 120;
+
+const CATEGORY_ICONS: Record<LocationSensorCategory, typeof Thermometer> = {
+  temperature: Thermometer,
+  humidity: Droplets,
+  battery: Battery,
+};
+
+const CATEGORY_UNITS: Record<LocationSensorCategory, string> = {
+  temperature: '°C',
+  humidity: '%',
+  battery: '%',
+};
+
+// Keep in step with categoryFor in LocationHASensorModal — "moisture" is
+// binary wet/dry, not a humidity percentage, and is not a location category.
+function categoryFor(deviceClass: string | null): LocationSensorCategory | null {
+  if (deviceClass === 'temperature') return 'temperature';
+  if (deviceClass === 'humidity') return 'humidity';
+  if (deviceClass === 'battery') return 'battery';
+  return null;
+}
+
+function CategorySection({
+  category,
+  state,
+  onChange,
+}: {
+  category: LocationSensorCategory;
+  state: LocationSensorCategoryDefaults;
+  onChange: (patch: Partial<LocationSensorCategoryDefaults>) => void;
+}) {
+  const { t } = useTranslation();
+  const Icon = CATEGORY_ICONS[category];
+  const unit = CATEGORY_UNITS[category];
+  const hasAlertCondition = state.alertAbove !== '' || state.alertBelow !== '';
+
+  return (
+    <div className="p-3 border border-bambu-dark-tertiary rounded-lg space-y-3">
+      <div className="flex items-center gap-1.5 text-sm text-white font-medium">
+        <Icon className="w-4 h-4" />
+        {t(`inventory.${category}`)}
+      </div>
+
+      {category === 'battery' ? (
+        <div>
+          <span className="block text-xs text-bambu-gray mb-1">
+            {t('haSensors.alertBelow')} {unit}
+          </span>
+          <input
+            type="number"
+            step="any"
+            value={state.alertBelow}
+            onChange={(e) => onChange({ alertBelow: e.target.value })}
+            className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white"
+          />
+        </div>
+      ) : (
+        <div className="grid grid-cols-2 gap-3">
+          <div>
+            <span className="block text-xs text-bambu-gray mb-1">
+              {t('haSensors.alertAbove')} {unit}
+            </span>
+            <input
+              type="number"
+              step="any"
+              value={state.alertAbove}
+              onChange={(e) => onChange({ alertAbove: e.target.value })}
+              className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white"
+            />
+          </div>
+          <div>
+            <span className="block text-xs text-bambu-gray mb-1">
+              {t('haSensors.alertBelow')} {unit}
+            </span>
+            <input
+              type="number"
+              step="any"
+              value={state.alertBelow}
+              onChange={(e) => onChange({ alertBelow: e.target.value })}
+              className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white"
+            />
+          </div>
+        </div>
+      )}
+
+      <label className="flex items-center gap-3 cursor-pointer">
+        <input
+          type="checkbox"
+          checked={state.showOnCard}
+          onChange={(e) => onChange({ showOnCard: e.target.checked })}
+          className="w-4 h-4"
+        />
+        <span className="text-sm text-white">{t('locationHaSensors.showOnCard')}</span>
+      </label>
+
+      <label className="flex items-center gap-3 cursor-pointer">
+        <input
+          type="checkbox"
+          checked={state.notifyOnAlert}
+          onChange={(e) => onChange({ notifyOnAlert: e.target.checked })}
+          disabled={!hasAlertCondition}
+          className="w-4 h-4"
+        />
+        <span className={`text-sm ${hasAlertCondition ? 'text-white' : 'text-bambu-gray'}`}>
+          {t('haSensors.notifyOnAlert')}
+        </span>
+      </label>
+    </div>
+  );
+}
+
+export function LocationSensorOptionsModal({ onClose }: Props) {
+  const { t } = useTranslation();
+  const { showToast } = useToast();
+  const queryClient = useQueryClient();
+  // Built-ins first, then seeded from the server once the settings query
+  // lands. The alert fields come from `location_sensor_alert_defaults`;
+  // show-on-card is still local.
+  const [defaults, setDefaults] = useState<LocationSensorDefaults>(() => loadLocationSensorDefaults());
+  const [colorizeValues, setColorizeValues] = useState(() => loadLocationSensorColorizeValues());
+  const [aboveColor, setAboveColor] = useState<LocationSensorAlertColor>(() => loadLocationSensorAlertAboveColor());
+  const [belowColor, setBelowColor] = useState<LocationSensorAlertColor>(() => loadLocationSensorAlertBelowColor());
+  const [optimalColor, setOptimalColor] = useState<LocationSensorAlertColor>(() => loadLocationSensorAlertOptimalColor());
+  const [showResetConfirm, setShowResetConfirm] = useState(false);
+
+  const { data: appSettings } = useQuery({ queryKey: ['settings'], queryFn: api.getSettings });
+  const [pollInterval, setPollInterval] = useState(DEFAULT_POLL_INTERVAL);
+
+  // Seed the server-backed fields once, and never over an edit in progress.
+  // The dialog renders immediately with placeholder values, so a settings
+  // response landing mid-keystroke would otherwise put the old value back in
+  // front of what was just typed (a field cleared and retyped came out as
+  // "3035"). In practice ['settings'] is warm — SettingsPage, which opens this
+  // dialog, already holds it — so this only covers the cold path and a
+  // background refetch.
+  //
+  // "Seeded" and "touched" are tracked apart on purpose. One flag for both
+  // means a keystroke that beats the response cancels the seed outright, and
+  // Save then writes built-in defaults over the server values for every field
+  // the user never saw. Seeding therefore always happens; it just skips the
+  // fields already edited, which are named here rather than counted.
+  const seeded = useRef(false);
+  const touched = useRef(new Set<LocationSensorCategory | 'pollInterval'>());
+  useEffect(() => {
+    if (!appSettings || seeded.current) return;
+    seeded.current = true;
+    if (!touched.current.has('pollInterval')) setPollInterval(appSettings.location_sensor_poll_interval);
+    const fromServer = loadLocationSensorDefaults(appSettings.location_sensor_alert_defaults);
+    setDefaults((prev) => {
+      const next = { ...fromServer };
+      (Object.keys(next) as LocationSensorCategory[]).forEach((category) => {
+        if (touched.current.has(category)) next[category] = prev[category];
+      });
+      return next;
+    });
+  }, [appSettings]);
+
+  const updateCategory = (category: LocationSensorCategory, patch: Partial<LocationSensorCategoryDefaults>) => {
+    touched.current.add(category);
+    setDefaults((prev) => ({ ...prev, [category]: { ...prev[category], ...patch } }));
+  };
+
+  const updatePollInterval = (value: number) => {
+    touched.current.add('pollInterval');
+    setPollInterval(value);
+  };
+
+  const persistDefaults = async () => {
+    // Server call first, and only for fields that actually changed (these
+    // need SETTINGS_UPDATE/admin), before writing anything to localStorage.
+    // A failed PATCH must leave the local preferences below untouched, so the
+    // error toast that follows is true — nothing was saved, not "half of it
+    // was".
+    const clampedInterval = Math.max(MIN_POLL_INTERVAL, pollInterval);
+    const alertDefaults = serializeLocationSensorAlertDefaults({
+      ...defaults,
+      battery: { ...defaults.battery, alertAbove: '' },
+    });
+    const patch: AppSettingsUpdate = {};
+    if (appSettings && clampedInterval !== appSettings.location_sensor_poll_interval) {
+      patch.location_sensor_poll_interval = clampedInterval;
+    }
+    if (appSettings && alertDefaults !== appSettings.location_sensor_alert_defaults) {
+      patch.location_sensor_alert_defaults = alertDefaults;
+    }
+    if (Object.keys(patch).length > 0) {
+      await api.updateSettings(patch);
+      queryClient.invalidateQueries({ queryKey: ['settings'] });
+    }
+
+    saveLocationSensorShowOnCardDefaults(defaults);
+    saveLocationSensorColorizeValues(colorizeValues);
+    saveLocationSensorAlertAboveColor(aboveColor);
+    saveLocationSensorAlertBelowColor(belowColor);
+    saveLocationSensorAlertOptimalColor(optimalColor);
+  };
+
+  const saveMutation = useMutation({
+    mutationFn: persistDefaults,
+    onSuccess: () => {
+      showToast(t('locationHaSensors.options.saved'), 'success');
+      onClose();
+    },
+    onError: (err: Error) => {
+      showToast(err.message || t('locationHaSensors.options.saveFailed'), 'error');
+    },
+  });
+
+  const handleSave = (e: React.FormEvent) => {
+    e.preventDefault();
+    saveMutation.mutate();
+  };
+
+  const resetMutation = useMutation({
+    mutationFn: async () => {
+      // Sensors first, options after — the same write-order rule Save follows,
+      // one level up. Reset is the risky half: it rewrites every bound sensor,
+      // and if that fails the error toast has to mean "nothing was saved". The
+      // per-sensor PATCHes below stay individually non-atomic (there is no bulk
+      // route), so a failure part-way still leaves some rows reset — but it no
+      // longer also leaves the options saved against a reset that half ran.
+      const [sensors, entities] = await Promise.all([api.getLocationHASensors(), api.getBindableLocationHAEntities()]);
+      const friendlyNameByEntityId = new Map(entities.map((entity) => [entity.entity_id, entity.friendly_name]));
+      const targets = sensors.filter((sensor) => categoryFor(sensor.device_class) !== null);
+      await Promise.all(
+        targets.map((sensor) => {
+          const category = categoryFor(sensor.device_class)!;
+          const categoryDefaults = defaults[category];
+          const friendlyName = friendlyNameByEntityId.get(sensor.entity_id);
+          return api.updateLocationHASensor(sensor.id, {
+            ...(friendlyName ? { name: friendlyName.slice(0, 100) } : {}),
+            alert_above:
+              category !== 'battery' && categoryDefaults.alertAbove !== '' ? Number(categoryDefaults.alertAbove) : null,
+            alert_below: categoryDefaults.alertBelow !== '' ? Number(categoryDefaults.alertBelow) : null,
+            notify_on_alert: categoryDefaults.notifyOnAlert,
+            show_on_card: categoryDefaults.showOnCard,
+          });
+        })
+      );
+      await persistDefaults();
+      return targets.length;
+    },
+    onSuccess: (count) => {
+      queryClient.invalidateQueries({ queryKey: ['locationHaSensors'] });
+      queryClient.invalidateQueries({ queryKey: ['locationHaSensorReadings'] });
+      showToast(t('locationHaSensors.options.resetDone', { count }), 'success');
+      setShowResetConfirm(false);
+      onClose();
+    },
+    onError: (err: Error) => {
+      showToast(err.message || t('locationHaSensors.options.resetFailed'), 'error');
+      setShowResetConfirm(false);
+    },
+  });
+
+  return (
+    <>
+    <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4" onClick={onClose}>
+      <div
+        className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-lg max-h-[90vh] overflow-y-auto"
+        onClick={(e) => e.stopPropagation()}
+      >
+        <div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
+          <div className="flex items-center gap-3">
+            <div className="p-2 rounded-full bg-bambu-green/20 text-bambu-green">
+              <Settings2 className="w-5 h-5" />
+            </div>
+            <h2 className="text-lg font-semibold text-white">{t('locationHaSensors.options.title')}</h2>
+          </div>
+          <button onClick={onClose} className="text-bambu-gray hover:text-white transition-colors">
+            <X className="w-5 h-5" />
+          </button>
+        </div>
+
+        <form onSubmit={handleSave} className="px-6 pb-6 pt-3 space-y-4">
+          <p className="text-xs text-bambu-gray">{t('locationHaSensors.options.description')}</p>
+
+          <div className="space-y-3">
+            <CategorySection
+              category="temperature"
+              state={defaults.temperature}
+              onChange={(patch) => updateCategory('temperature', patch)}
+            />
+            <CategorySection
+              category="humidity"
+              state={defaults.humidity}
+              onChange={(patch) => updateCategory('humidity', patch)}
+            />
+            <CategorySection
+              category="battery"
+              state={defaults.battery}
+              onChange={(patch) => updateCategory('battery', patch)}
+            />
+          </div>
+
+          <div className="p-3 border border-bambu-dark-tertiary rounded-lg space-y-3">
+            <label className="flex items-center gap-3 cursor-pointer">
+              <input
+                type="checkbox"
+                checked={colorizeValues}
+                onChange={(e) => setColorizeValues(e.target.checked)}
+                className="w-4 h-4"
+              />
+              <span className="text-sm text-white">{t('locationHaSensors.options.colorizeValues')}</span>
+            </label>
+
+            <div className={`grid grid-cols-3 gap-x-3 gap-y-1 items-end ${colorizeValues ? '' : 'opacity-50'}`}>
+              <label className="block text-xs text-bambu-gray" htmlFor="location-sensor-below-color">
+                {t('locationHaSensors.options.belowColor')}
+              </label>
+              <label className="block text-xs text-bambu-gray" htmlFor="location-sensor-optimal-color">
+                {t('locationHaSensors.options.optimalColor')}
+              </label>
+              <label className="block text-xs text-bambu-gray" htmlFor="location-sensor-above-color">
+                {t('locationHaSensors.options.aboveColor')}
+              </label>
+              <select
+                id="location-sensor-below-color"
+                value={belowColor}
+                onChange={(e) => setBelowColor(e.target.value as LocationSensorAlertColor)}
+                disabled={!colorizeValues}
+                className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white disabled:cursor-not-allowed"
+              >
+                {LOCATION_SENSOR_ALERT_COLORS.map((color) => (
+                  <option key={color} value={color}>
+                    {t(`locationHaSensors.options.colors.${color}`)}
+                  </option>
+                ))}
+              </select>
+              <select
+                id="location-sensor-optimal-color"
+                value={optimalColor}
+                onChange={(e) => setOptimalColor(e.target.value as LocationSensorAlertColor)}
+                disabled={!colorizeValues}
+                className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white disabled:cursor-not-allowed"
+              >
+                {LOCATION_SENSOR_ALERT_COLORS.map((color) => (
+                  <option key={color} value={color}>
+                    {t(`locationHaSensors.options.colors.${color}`)}
+                  </option>
+                ))}
+              </select>
+              <select
+                id="location-sensor-above-color"
+                value={aboveColor}
+                onChange={(e) => setAboveColor(e.target.value as LocationSensorAlertColor)}
+                disabled={!colorizeValues}
+                className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white disabled:cursor-not-allowed"
+              >
+                {LOCATION_SENSOR_ALERT_COLORS.map((color) => (
+                  <option key={color} value={color}>
+                    {t(`locationHaSensors.options.colors.${color}`)}
+                  </option>
+                ))}
+              </select>
+            </div>
+          </div>
+
+          {/* Everything above is local display preference (localStorage); the
+              poll interval below is the one field here that actually lives on
+              the server, hence its own heading. */}
+          <div className="pt-4 mt-4 border-t border-bambu-dark-tertiary">
+            <p className="text-xs font-medium text-bambu-gray uppercase tracking-wider mb-3">
+              {t('locationHaSensors.options.generalSettings')}
+            </p>
+          </div>
+
+          <div className="p-3 border border-bambu-dark-tertiary rounded-lg space-y-2">
+            <label className="block text-sm text-white" htmlFor="location-sensor-poll-interval">
+              {t('locationHaSensors.options.pollInterval')}
+            </label>
+            <input
+              id="location-sensor-poll-interval"
+              type="number"
+              min={MIN_POLL_INTERVAL}
+              step="1"
+              value={pollInterval}
+              onChange={(e) => updatePollInterval(Number(e.target.value))}
+              onBlur={() => updatePollInterval(Math.max(MIN_POLL_INTERVAL, pollInterval || DEFAULT_POLL_INTERVAL))}
+              className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white"
+            />
+            <p className="text-xs text-bambu-gray">{t('locationHaSensors.options.pollIntervalHint')}</p>
+          </div>
+
+          <div className="flex items-center justify-between gap-2 pt-2">
+            <Button type="button" variant="secondary" onClick={() => setShowResetConfirm(true)}>
+              <RotateCcw className="w-4 h-4" />
+              {t('locationHaSensors.options.reset')}
+            </Button>
+            <div className="flex items-center gap-2">
+              <Button type="button" variant="secondary" onClick={onClose}>
+                {t('common.cancel')}
+              </Button>
+              <Button type="submit" disabled={saveMutation.isPending}>
+                <Save className="w-4 h-4" />
+                {t('common.save')}
+              </Button>
+            </div>
+          </div>
+        </form>
+      </div>
+    </div>
+
+    {showResetConfirm && (
+      <ConfirmModal
+        title={t('locationHaSensors.options.resetConfirm.title')}
+        message={t('locationHaSensors.options.resetConfirm.message')}
+        confirmText={t('locationHaSensors.options.reset')}
+        variant="danger"
+        overlayZIndex="z-[60]"
+        isLoading={resetMutation.isPending}
+        onConfirm={() => resetMutation.mutate()}
+        onCancel={() => setShowResetConfirm(false)}
+      />
+    )}
+    </>
+  );
+}

+ 103 - 33
frontend/src/components/LocationsModal.tsx

@@ -11,10 +11,14 @@ import { inventoryLocationsQueryKey, invalidateInventoryLocations } from '../uti
 interface LocationsModalProps {
   open: boolean;
   onClose: () => void;
+  // Optional even with startCreating: a caller that just wants the inline
+  // "create a location" dialog without picking one afterward can omit it.
+  // The save always closes the modal regardless of whether this is set.
   onPickLocation?: (locationId: number) => void;
+  startCreating?: boolean;
 }
 
-export function LocationsModal({ open, onClose, onPickLocation }: LocationsModalProps) {
+export function LocationsModal({ open, onClose, onPickLocation, startCreating }: LocationsModalProps) {
   const { t } = useTranslation();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
@@ -30,10 +34,23 @@ export function LocationsModal({ open, onClose, onPickLocation }: LocationsModal
     enabled: open,
   });
 
+  const { data: locationSensors = [] } = useQuery({
+    queryKey: ['locationHaSensors'],
+    queryFn: () => api.getLocationHASensors(),
+    enabled: open,
+  });
+
+  const sensorCountByLocation = locationSensors.reduce<Record<number, number>>((acc, sensor) => {
+    acc[sensor.location_id] = (acc[sensor.location_id] ?? 0) + 1;
+    return acc;
+  }, {});
+
   const invalidate = () => {
     invalidateInventoryLocations(queryClient);
     queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
     queryClient.invalidateQueries({ queryKey: ['spoolman-inventory-spools'] });
+    queryClient.invalidateQueries({ queryKey: ['locationHaSensors'] });
+    queryClient.invalidateQueries({ queryKey: ['locationHaSensorReadings'] });
   };
 
   const saveMutation = useMutation({
@@ -45,12 +62,24 @@ export function LocationsModal({ open, onClose, onPickLocation }: LocationsModal
       }
       return api.createLocation({ name: trimmed });
     },
-    onSuccess: () => {
+    onSuccess: (saved) => {
       showToast(t(editing ? 'locations.updated' : 'locations.created'), 'success');
+      invalidate();
+      // startCreating mode has no location-list view to fall back to (see the
+      // render branch below and closeEditor's own unconditional onClose), so
+      // a save here must always close — with or without onPickLocation, which
+      // is optional by design for a caller that only wants location
+      // management, not a picker. Gating this on onPickLocation being set
+      // used to leave editorOpen false with open still true: nothing left to
+      // render, but the caller never told to close.
+      if (!editing && startCreating) {
+        onPickLocation?.(saved.id);
+        onClose();
+        return;
+      }
       setEditorOpen(false);
       setEditing(null);
       setName('');
-      invalidate();
     },
     onError: (err: Error) => {
       showToast(err.message || t('locations.saveFailed'), 'error');
@@ -75,6 +104,14 @@ export function LocationsModal({ open, onClose, onPickLocation }: LocationsModal
     setEditorOpen(true);
   };
 
+  useEffect(() => {
+    if (open && startCreating) {
+      setEditing(null);
+      setName('');
+      setEditorOpen(true);
+    }
+  }, [open, startCreating]);
+
   const openEdit = (location: StorageLocation) => {
     setEditing(location);
     setName(location.name);
@@ -83,10 +120,14 @@ export function LocationsModal({ open, onClose, onPickLocation }: LocationsModal
 
   const closeEditor = useCallback(() => {
     if (saveMutation.isPending) return;
+    if (startCreating) {
+      onClose();
+      return;
+    }
     setEditorOpen(false);
     setEditing(null);
     setName('');
-  }, [saveMutation.isPending]);
+  }, [saveMutation.isPending, startCreating, onClose]);
 
   // Esc closes the inner editor first; if it's closed, Esc closes the outer
   // modal — but only when neither save nor delete is mid-flight, so a stray
@@ -117,6 +158,52 @@ export function LocationsModal({ open, onClose, onPickLocation }: LocationsModal
   const modalTitleId = 'locations-modal-title';
   const editorTitleId = 'location-editor-title';
 
+  const editorForm = (
+    <form onSubmit={handleSave}>
+      <label className="block text-sm font-medium text-bambu-gray mb-1" htmlFor="location-name">
+        {t('locations.name')}
+      </label>
+      <input
+        id="location-name"
+        type="text"
+        maxLength={255}
+        className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green mb-4"
+        placeholder={t('locations.createPlaceholder')}
+        value={name}
+        onChange={(e) => setName(e.target.value)}
+        autoFocus
+      />
+      <div className="flex justify-end gap-2">
+        <Button type="button" variant="secondary" onClick={closeEditor}>
+          {t('common.cancel')}
+        </Button>
+        <Button type="submit" disabled={saveMutation.isPending || !name.trim()}>
+          {saveMutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
+          {t('common.save')}
+        </Button>
+      </div>
+    </form>
+  );
+
+  if (startCreating) {
+    return editorOpen ? (
+      <div className="fixed inset-0 z-50 flex items-center justify-center">
+        <div className="absolute inset-0 bg-black/60" onClick={closeEditor} />
+        <div
+          className="relative w-full max-w-md mx-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl p-6 shadow-2xl"
+          role="dialog"
+          aria-modal="true"
+          aria-labelledby={editorTitleId}
+        >
+          <h3 id={editorTitleId} className="text-lg font-semibold text-white mb-4">
+            {t('locations.add')}
+          </h3>
+          {editorForm}
+        </div>
+      </div>
+    ) : null;
+  }
+
   return (
     <div className="fixed inset-0 z-50 flex items-center justify-center">
       <div
@@ -165,11 +252,12 @@ export function LocationsModal({ open, onClose, onPickLocation }: LocationsModal
           ) : locations.length === 0 ? (
             <div className="py-16 text-center text-bambu-gray">{t('locations.empty')}</div>
           ) : (
-            <table className="w-full text-sm">
+            <table className="w-full text-sm table-fixed">
               <thead>
                 <tr className="border-b border-bambu-dark-tertiary text-left text-bambu-gray">
                   <th className="px-4 py-3 font-medium">{t('locations.name')}</th>
-                  <th className="px-4 py-3 font-medium text-right">{t('locations.spools')}</th>
+                  <th className="px-2 py-3 font-medium text-right w-24">{t('locations.sensors')}</th>
+                  <th className="pl-[28px] pr-4 py-3 font-medium text-right w-28">{t('locations.spools')}</th>
                   <th className="px-4 py-3 font-medium text-right w-32">{t('common.actions')}</th>
                 </tr>
               </thead>
@@ -185,8 +273,9 @@ export function LocationsModal({ open, onClose, onPickLocation }: LocationsModal
                       }
                     }}
                   >
-                    <td className="px-4 py-3 text-white font-medium">{loc.name}</td>
-                    <td className="px-4 py-3 text-right text-bambu-gray">{loc.spool_count}</td>
+                    <td className="px-4 py-3 text-white font-medium truncate">{loc.name}</td>
+                    <td className="px-2 py-3 text-right text-bambu-gray">{sensorCountByLocation[loc.id] ?? 0}</td>
+                    <td className="pl-[28px] pr-4 py-3 text-right text-bambu-gray">{loc.spool_count}</td>
                     <td className="px-4 py-3 text-right" onClick={(e) => e.stopPropagation()}>
                       <div className="flex items-center justify-end gap-1">
                         <button
@@ -230,30 +319,7 @@ export function LocationsModal({ open, onClose, onPickLocation }: LocationsModal
             <h3 id={editorTitleId} className="text-lg font-semibold text-white mb-4">
               {editing ? t('locations.edit') : t('locations.add')}
             </h3>
-            <form onSubmit={handleSave}>
-              <label className="block text-sm font-medium text-bambu-gray mb-1" htmlFor="location-name">
-                {t('locations.name')}
-              </label>
-              <input
-                id="location-name"
-                type="text"
-                maxLength={255}
-                className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green mb-4"
-                placeholder={t('locations.createPlaceholder')}
-                value={name}
-                onChange={(e) => setName(e.target.value)}
-                autoFocus
-              />
-              <div className="flex justify-end gap-2">
-                <Button type="button" variant="secondary" onClick={closeEditor}>
-                  {t('common.cancel')}
-                </Button>
-                <Button type="submit" disabled={saveMutation.isPending || !name.trim()}>
-                  {saveMutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
-                  {t('common.save')}
-                </Button>
-              </div>
-            </form>
+            {editorForm}
           </div>
         </div>
       )}
@@ -261,7 +327,11 @@ export function LocationsModal({ open, onClose, onPickLocation }: LocationsModal
       {deleteTarget && (
         <ConfirmModal
           title={t('locations.confirmDelete', { name: deleteTarget.name })}
-          message={t('locations.confirmDeleteMessage')}
+          message={
+            sensorCountByLocation[deleteTarget.id]
+              ? t('locations.confirmDeleteMessageWithSensors')
+              : t('locations.confirmDeleteMessage')
+          }
           confirmText={t('common.delete')}
           variant="danger"
           isLoading={deleteMutation.isPending}

+ 14 - 0
frontend/src/components/NotificationProviderCard.tsx

@@ -150,6 +150,9 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
             {provider.on_ha_sensor_alert && (
               <span className="px-2 py-0.5 bg-indigo-100 dark:bg-indigo-500/20 text-indigo-700 dark:text-indigo-300 text-xs rounded">{t('notifications.haSensorAlert')}</span>
             )}
+            {provider.on_location_ha_sensor_alert && (
+              <span className="px-2 py-0.5 bg-indigo-100 dark:bg-indigo-500/20 text-indigo-700 dark:text-indigo-300 text-xs rounded">{t('notifications.locationHaSensorAlert')}</span>
+            )}
             {provider.on_filament_low && (
               <span className="px-2 py-0.5 bg-cyan-100 dark:bg-cyan-500/20 text-cyan-700 dark:text-cyan-400 text-xs rounded">{t('notifications.lowFilament')}</span>
             )}
@@ -425,6 +428,17 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
                   />
                 </div>
 
+                <div className="flex items-center justify-between">
+                  <div>
+                    <p className="text-sm text-white">{t('notifications.locationHaSensorAlert')}</p>
+                    <p className="text-xs text-bambu-gray">{t('notifications.locationHaSensorAlertDescription')}</p>
+                  </div>
+                  <Toggle
+                    checked={provider.on_location_ha_sensor_alert ?? false}
+                    onChange={(checked) => updateMutation.mutate({ on_location_ha_sensor_alert: checked })}
+                  />
+                </div>
+
                 <div className="flex items-center justify-between">
                   <p className="text-sm text-white">{t('notifications.lowFilamentLabel')}</p>
 <Toggle

+ 4 - 75
frontend/src/components/PrinterHASensorRow.tsx

@@ -1,21 +1,10 @@
 import { useQuery } from '@tanstack/react-query';
-import {
-  Activity,
-  AlertTriangle,
-  DoorClosed,
-  DoorOpen,
-  Droplets,
-  Gauge,
-  Lock,
-  LockOpen,
-  Thermometer,
-  Wind,
-} from 'lucide-react';
-import type { LucideIcon } from 'lucide-react';
+import { Gauge } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 
 import { api } from '../api/client';
 import type { PrinterHASensorReading } from '../api/client';
+import { describeHASensorReading, iconForHASensor } from '../utils/haSensorDisplay';
 
 /**
  * The Home Assistant sensors bound to a printer, on its card (#1148, #448).
@@ -25,57 +14,6 @@ import type { PrinterHASensorReading } from '../api/client';
  * you can actually operate.
  */
 
-// Home Assistant's own device_class decides the wording, so a door reads
-// "Open"/"Closed" rather than the "on"/"off" the API actually carries. Classes
-// absent from this map fall through to on/off, which is what HA itself shows
-// for a binary_sensor with no class.
-const BINARY_LABELS: Record<string, { on: string; off: string }> = {
-  door: { on: 'open', off: 'closed' },
-  garage_door: { on: 'open', off: 'closed' },
-  window: { on: 'open', off: 'closed' },
-  opening: { on: 'open', off: 'closed' },
-  lock: { on: 'unlocked', off: 'locked' },
-  motion: { on: 'detected', off: 'clear' },
-  occupancy: { on: 'detected', off: 'clear' },
-  presence: { on: 'detected', off: 'clear' },
-  smoke: { on: 'detected', off: 'clear' },
-  gas: { on: 'detected', off: 'clear' },
-  moisture: { on: 'wet', off: 'dry' },
-  problem: { on: 'problem', off: 'ok' },
-  safety: { on: 'problem', off: 'ok' },
-  running: { on: 'running', off: 'stopped' },
-};
-
-const ICONS: Record<string, LucideIcon> = {
-  door: DoorOpen,
-  garage_door: DoorOpen,
-  window: DoorOpen,
-  opening: DoorOpen,
-  lock: LockOpen,
-  temperature: Thermometer,
-  humidity: Droplets,
-  moisture: Droplets,
-  motion: Activity,
-  occupancy: Activity,
-  presence: Activity,
-  smoke: AlertTriangle,
-  gas: AlertTriangle,
-  problem: AlertTriangle,
-  safety: AlertTriangle,
-  running: Wind,
-};
-
-function iconFor(reading: PrinterHASensorReading): LucideIcon {
-  const deviceClass = reading.device_class ?? '';
-  // A closed door wants the closed-door glyph — the map is keyed by class, so
-  // the two states that have a distinct "off" icon are special-cased here.
-  if (reading.state === 'off') {
-    if (ICONS[deviceClass] === DoorOpen) return DoorClosed;
-    if (ICONS[deviceClass] === LockOpen) return Lock;
-  }
-  return ICONS[deviceClass] ?? (reading.kind === 'numeric' ? Gauge : Activity);
-}
-
 interface Props {
   printerId: number;
 }
@@ -94,16 +32,7 @@ export function PrinterHASensorRow({ printerId }: Props) {
 
   if (!readings?.length) return null;
 
-  const describe = (reading: PrinterHASensorReading): string => {
-    if (!reading.reachable || reading.state === null) return t('haSensors.unavailable');
-    if (reading.kind === 'numeric') {
-      if (reading.value === null) return reading.state;
-      return reading.unit ? `${reading.value} ${reading.unit}` : String(reading.value);
-    }
-    const labels = BINARY_LABELS[reading.device_class ?? ''];
-    const key = labels ? labels[reading.state === 'on' ? 'on' : 'off'] : reading.state;
-    return t(`haSensors.states.${key}`, { defaultValue: key });
-  };
+  const describe = (reading: PrinterHASensorReading): string => describeHASensorReading(reading, t);
 
   return (
     <div className="flex items-center gap-2 mt-2">
@@ -112,7 +41,7 @@ export function PrinterHASensorRow({ printerId }: Props) {
       <div className="h-[2px] w-5 bg-bambu-dark-tertiary/50" />
       <div className="flex flex-wrap gap-1">
         {readings.map((reading) => {
-          const Icon = iconFor(reading);
+          const Icon = iconForHASensor(reading);
           const unreachable = !reading.reachable || reading.state === null;
           return (
             <span

+ 79 - 2
frontend/src/i18n/locales/de.ts

@@ -1846,6 +1846,7 @@ export default {
     tabs: {
       general: 'Allgemein',
       smartPlugs: 'Smart Plugs',
+      sensors: 'Sensoren',
       notifications: 'Benachrichtigungen',
       queue: 'Workflow',
       queueDispatch: 'Warteschlange & Dispatch',
@@ -4501,6 +4502,7 @@ export default {
     edit: 'Lagerort bearbeiten',
     name: 'Name',
     spools: 'Spulen',
+    sensors: 'Sensoren',
     empty: 'Noch keine Lagerorte. Erstellen Sie Ihr erstes Regal oder Ihre erste Schublade.',
     manage: 'Lagerorte',
     createPlaceholder: 'z. B. Regal A, Schublade 1',
@@ -4513,6 +4515,7 @@ export default {
     deleteBlocked: 'Entfernen Sie zuerst alle Spulen von diesem Lagerort',
     confirmDelete: '„{{name}}“ löschen?',
     confirmDeleteMessage: 'Dieser Lagerort wird aus dem Katalog entfernt. Spulen müssen zuerst verschoben werden.',
+    confirmDeleteMessageWithSensors: 'Dieser Lagerort wird aus dem Katalog entfernt. Spulen müssen zuerst verschoben werden. Verbundene Sensoren werden ebenfalls gelöscht.',
   },
 
   // Inventar
@@ -4679,6 +4682,9 @@ export default {
     spoolName: 'Spule',
     costPerKg: 'Kosten pro kg',
     storageLocation: 'Lagerstandort',
+    temperature: 'Temperatur',
+    humidity: 'Luftfeuchtigkeit',
+    battery: 'Batterie',
     storageLocationPlaceholder: 'z.B. Regal A, Schublade 1',
     openInInventory: 'Im Inventar öffnen',
     measuredWeightError: 'Das gemessene Gewicht muss zwischen {{min}}g und {{max}}g liegen.',
@@ -5746,7 +5752,7 @@ export default {
       on: 'An',
       off: 'Aus',
     },
-    sectionTitle: 'Home-Assistant-Sensoren',
+    sectionTitle: 'Home-Assistant-Sensoren (Drucker)',
     add: 'Sensor hinzufügen',
     addTitle: 'Home-Assistant-Sensor hinzufügen',
     editTitle: 'Home-Assistant-Sensor bearbeiten',
@@ -5781,6 +5787,75 @@ export default {
       alertRequired: 'Lege zuerst eine Alarmbedingung fest',
     },
   },
+  locationHaSensors: {
+    sectionTitle: 'Home-Assistant-Sensoren (Lagerorte)',
+    sectionDescription:
+      'Binden Sie Home-Assistant-Temperatur-, Luftfeuchtigkeits- oder Batteriesensoren an Ihre Lagerorte, um Live-Werte auf der Filament-Karte und in der Tabelle anzuzeigen. Pro Lagerort ist nur ein Sensor je Kategorie möglich.',
+    add: 'Sensor hinzufügen',
+    addTitle: 'Home-Assistant-Sensor hinzufügen',
+    editTitle: 'Home-Assistant-Sensor bearbeiten',
+    empty: 'Noch keine Sensoren. Verknüpfen Sie einen Temperatur- oder Feuchtigkeitssensor aus Home Assistant, um ihn auf der Filament-Karte anzuzeigen.',
+    unknownLocation: 'Unbekannter Lagerort',
+    location: 'Lagerort',
+    showOnCard: 'Auf Filament-Karte anzeigen',
+    overwriteConfirm: {
+      title: 'Vorhandenen Sensor ersetzen?',
+      messageTemperature: '„{{location}}“ hat bereits einen Temperatursensor gebunden: {{name}}. Beim Fortfahren wird dieser ersetzt.',
+      messageHumidity: '„{{location}}“ hat bereits einen Luftfeuchtigkeitssensor gebunden: {{name}}. Beim Fortfahren wird dieser ersetzt.',
+      messageBattery: '„{{location}}“ hat bereits einen Batteriesensor gebunden: {{name}}. Beim Fortfahren wird dieser ersetzt.',
+    },
+    autoAdd: {
+      confirmTitle: 'Auch die anderen Sensoren hinzufügen?',
+      confirmMessage: 'Bambuddy hat für diesen Lagerort auch weitere Sensoren gefunden. Wählen Sie aus, welche zusätzlich hinzugefügt werden sollen.',
+      added: 'Zusätzlich hinzugefügt: {{names}}',
+      noneFound: 'Keine passenden Temperatur-, Luftfeuchtigkeits- oder Batteriesensoren für diesen Lagerort gefunden.',
+      onlyThisOne: 'Nur dieser',
+    },
+    options: {
+      buttonLabel: 'Sensor-Optionen',
+      title: 'Lagerort-Sensor-Optionen',
+      description: 'Diese Werte werden verwendet, wenn ein Temperatur-, Luftfeuchtigkeits- oder Batteriesensor automatisch mit einem Lagerort gebunden wird.',
+      generalSettings: 'Allgemeine Einstellungen',
+      pollInterval: 'Aktualisierungsintervall (Sekunden)',
+      pollIntervalHint: 'Wie oft Bambuddy Home Assistant abfragt und die Sensorwerte auf dem Bildschirm aktualisiert. Minimum 60 Sekunden.',
+      colorizeValues: 'Sensorwerte anhand ihrer Alarmschwellen farbig darstellen',
+      aboveColor: 'Farbe für Überschreitung',
+      belowColor: 'Farbe für Unterschreitung',
+      optimalColor: 'Farbe für Optimalwert',
+      colors: {
+        red: 'Rot',
+        orange: 'Orange',
+        yellow: 'Gelb',
+        green: 'Grün',
+        blue: 'Blau',
+        purple: 'Lila',
+        pink: 'Magenta',
+      },
+      saved: 'Optionen gespeichert',
+      saveFailed: 'Optionen konnten nicht gespeichert werden',
+      reset: 'Zurücksetzen',
+      resetConfirm: {
+        title: 'Alle bestehenden Lagerort-Sensoren zurücksetzen?',
+        message:
+          'Dies überschreibt die Alarmschwellen, Benachrichtigungen und Kartensichtbarkeit aller bestehenden Temperatur-, Luftfeuchtigkeits- und Batteriesensoren von Lagerorten mit den oben festgelegten Werten und setzt den Namen jedes Sensors auf seinen Home-Assistant-Anzeigenamen zurück – Drucker-Sensoren sind davon nicht betroffen. Dies kann nicht rückgängig gemacht werden. Je nach Anzahl der Sensoren kann dies einen Moment dauern.',
+      },
+      resetDone: 'Auf {{count}} Lagerort-Sensoren angewendet',
+      resetFailed: 'Zurücksetzen der Lagerort-Sensoren fehlgeschlagen',
+    },
+    deleteAllConfirm: {
+      title: 'Alle Sensoren löschen?',
+      message: 'Dadurch werden alle {{count}} Sensoren entfernt, die an „{{location}}“ gebunden sind. Dies kann nicht rückgängig gemacht werden.',
+    },
+    currentlyBound: 'Aktuell gebunden: {{entity}}',
+    error: {
+      locationRequired: 'Wählen Sie einen Lagerort',
+    },
+    toast: {
+      created: 'Sensor hinzugefügt',
+      updated: 'Sensor gespeichert',
+      deleted: 'Sensor entfernt',
+    },
+  },
   smartPlugs: {
     offline: 'Offline',
     admin: 'Admin',
@@ -6098,8 +6173,10 @@ export default {
     notificationEvents: 'Benachrichtigungsereignisse',
     progressPercent: '(25 %, 50 %, 75 %)',
     bedCooledAfterPrint: '(nach Druckabschluss)',
-    haSensorAlert: 'Sensor-Alarm',
+    haSensorAlert: 'Drucker-Sensor-Alarm',
     haSensorAlertDescription: '(ein verknüpfter Home-Assistant-Sensor braucht Aufmerksamkeit)',
+    locationHaSensorAlert: 'Lagerort-Sensor-Alarm',
+    locationHaSensorAlertDescription: '(ein mit einem Lagerort verknüpfter Home-Assistant-Sensor braucht Aufmerksamkeit)',
     // Per-event ntfy priority (#990)
     eventPriority: {
       sectionTitle: 'ntfy-Priorität',

+ 80 - 2
frontend/src/i18n/locales/en.ts

@@ -1864,6 +1864,7 @@ export default {
     tabs: {
       general: 'General',
       smartPlugs: 'Smart Plugs',
+      sensors: 'Sensors',
       notifications: 'Notifications',
       queue: 'Workflow',
       queueDispatch: 'Queue & Dispatch',
@@ -4536,6 +4537,7 @@ export default {
     edit: 'Edit Location',
     name: 'Name',
     spools: 'Spools',
+    sensors: 'Sensors',
     empty: 'No storage locations yet. Create your first shelf or drawer.',
     manage: 'Locations',
     createPlaceholder: 'e.g. Shelf A, Drawer 1',
@@ -4548,6 +4550,8 @@ export default {
     deleteBlocked: 'Remove all spools from this location before deleting',
     confirmDelete: 'Delete "{{name}}"?',
     confirmDeleteMessage: 'This location will be removed from the catalog. Spools must be moved first.',
+    confirmDeleteMessageWithSensors:
+      'This location will be removed from the catalog. Spools must be moved first. Connected sensors will also be deleted.',
   },
 
   // Inventory
@@ -4714,6 +4718,9 @@ export default {
     spoolName: 'Spool',
     costPerKg: 'Cost per kg',
     storageLocation: 'Storage Location',
+    temperature: 'Temperature',
+    humidity: 'Humidity',
+    battery: 'Battery',
     storageLocationPlaceholder: 'e.g. Shelf A, Drawer 1',
     openInInventory: 'Open in Inventory',
     measuredWeightError: 'Measured weight must be between {{min}}g and {{max}}g.',
@@ -5796,7 +5803,7 @@ export default {
       on: 'On',
       off: 'Off',
     },
-    sectionTitle: 'Home Assistant Sensors',
+    sectionTitle: 'Home Assistant Sensors (Printers)',
     add: 'Add Sensor',
     addTitle: 'Add Home Assistant Sensor',
     editTitle: 'Edit Home Assistant Sensor',
@@ -5831,6 +5838,75 @@ export default {
       alertRequired: 'Set an alert condition first',
     },
   },
+  locationHaSensors: {
+    sectionTitle: 'Home Assistant Sensors (Storage Locations)',
+    sectionDescription:
+      'Bind Home Assistant temperature, humidity, or battery sensors to your storage locations to show live readings on the filament card and in the table. Only one sensor per category is allowed per location.',
+    add: 'Add Sensor',
+    addTitle: 'Add Home Assistant Sensor',
+    editTitle: 'Edit Home Assistant Sensor',
+    empty: 'No sensors yet. Bind a temperature or humidity sensor from Home Assistant to show it on the filament card.',
+    unknownLocation: 'Unknown location',
+    location: 'Storage Location',
+    showOnCard: 'Show on filament card',
+    overwriteConfirm: {
+      title: 'Replace existing sensor?',
+      messageTemperature: '"{{location}}" already has a temperature sensor bound: {{name}}. Continuing will replace it.',
+      messageHumidity: '"{{location}}" already has a humidity sensor bound: {{name}}. Continuing will replace it.',
+      messageBattery: '"{{location}}" already has a battery sensor bound: {{name}}. Continuing will replace it.',
+    },
+    autoAdd: {
+      confirmTitle: 'Add the other sensors too?',
+      confirmMessage: 'Bambuddy also found other sensors for this location. Choose which ones to add too.',
+      added: 'Also added: {{names}}',
+      noneFound: 'No matching temperature, humidity, or battery sensors found for this location.',
+      onlyThisOne: 'Only this one',
+    },
+    options: {
+      buttonLabel: 'Sensor options',
+      title: 'Location Sensor Options',
+      description: 'These values are used when a temperature, humidity, or battery sensor is bound automatically alongside the first sensor for a location.',
+      generalSettings: 'General settings',
+      pollInterval: 'Update interval (seconds)',
+      pollIntervalHint: 'How often Bambuddy polls Home Assistant and refreshes sensor values on screen. Minimum 60 seconds.',
+      colorizeValues: 'Colorize sensor values against their alert thresholds',
+      aboveColor: 'Above threshold color',
+      belowColor: 'Below threshold color',
+      optimalColor: 'Optimal value color',
+      colors: {
+        red: 'Red',
+        orange: 'Orange',
+        yellow: 'Yellow',
+        green: 'Green',
+        blue: 'Blue',
+        purple: 'Purple',
+        pink: 'Pink',
+      },
+      saved: 'Options saved',
+      saveFailed: 'Failed to save options',
+      reset: 'Reset',
+      resetConfirm: {
+        title: 'Reset all existing location sensors?',
+        message:
+          'This overwrites the alert thresholds, notifications, and card visibility of every existing storage-location temperature, humidity, and battery sensor with the values configured above, and restores each sensor\'s name to its Home Assistant friendly name — printer sensors are not affected. This cannot be undone. Depending on how many sensors you have, this may take a moment.',
+      },
+      resetDone: 'Applied to {{count}} location sensors',
+      resetFailed: 'Failed to reset location sensors',
+    },
+    deleteAllConfirm: {
+      title: 'Delete all sensors?',
+      message: 'This removes all {{count}} sensors bound to "{{location}}". This cannot be undone.',
+    },
+    currentlyBound: 'Currently bound: {{entity}}',
+    error: {
+      locationRequired: 'Pick a storage location',
+    },
+    toast: {
+      created: 'Sensor added',
+      updated: 'Sensor saved',
+      deleted: 'Sensor removed',
+    },
+  },
   smartPlugs: {
     offline: 'Offline',
     admin: 'Admin',
@@ -6148,8 +6224,10 @@ export default {
     notificationEvents: 'Notification Events',
     progressPercent: '(25%, 50%, 75%)',
     bedCooledAfterPrint: '(after print completes)',
-    haSensorAlert: 'Sensor Alert',
+    haSensorAlert: 'Printer Sensor Alert',
     haSensorAlertDescription: '(a bound Home Assistant sensor needs attention)',
+    locationHaSensorAlert: 'Storage Location Sensor Alert',
+    locationHaSensorAlertDescription: '(a Home Assistant sensor bound to a storage location needs attention)',
     // Per-event ntfy priority (#990)
     eventPriority: {
       sectionTitle: 'ntfy Priority',

+ 79 - 2
frontend/src/i18n/locales/es.ts

@@ -1847,6 +1847,7 @@ export default {
     tabs: {
       general: 'General',
       smartPlugs: 'Enchufes inteligentes',
+      sensors: 'Sensores',
       notifications: 'Notificaciones',
       queue: 'Flujo de trabajo',
       queueDispatch: 'Cola y Despacho',
@@ -4503,6 +4504,7 @@ export default {
     edit: 'Editar ubicación',
     name: 'Nombre',
     spools: 'Bobinas',
+    sensors: 'Sensores',
     empty: 'Aún no hay ubicaciones de almacenamiento. Cree su primer estante o cajón.',
     manage: 'Ubicaciones',
     createPlaceholder: 'p. ej. Estante A, Cajón 1',
@@ -4515,6 +4517,7 @@ export default {
     deleteBlocked: 'Retire todas las bobinas de esta ubicación antes de eliminarla',
     confirmDelete: '¿Eliminar «{{name}}»?',
     confirmDeleteMessage: 'Esta ubicación se eliminará del catálogo. Mueva las bobinas primero.',
+    confirmDeleteMessageWithSensors: 'Esta ubicación se eliminará del catálogo. Mueva las bobinas primero. Los sensores conectados también se eliminarán.',
   },
 
   // Inventory
@@ -4681,6 +4684,9 @@ export default {
     spoolName: 'Bobina',
     costPerKg: 'Coste por kg',
     storageLocation: 'Ubicación de almacenamiento',
+    temperature: 'Temperatura',
+    humidity: 'Humedad',
+    battery: 'Batería',
     storageLocationPlaceholder: 'p. ej. Estante A, Cajón 1',
     openInInventory: 'Abrir en el inventario',
     measuredWeightError: 'El peso medido debe estar entre {{min}} g y {{max}} g.',
@@ -5754,7 +5760,7 @@ export default {
       on: 'Encendido',
       off: 'Apagado',
     },
-    sectionTitle: 'Sensores de Home Assistant',
+    sectionTitle: 'Sensores de Home Assistant (impresoras)',
     add: 'Añadir sensor',
     addTitle: 'Añadir sensor de Home Assistant',
     editTitle: 'Editar sensor de Home Assistant',
@@ -5789,6 +5795,75 @@ export default {
       alertRequired: 'Define primero una condición de alerta',
     },
   },
+  locationHaSensors: {
+    sectionTitle: 'Sensores de Home Assistant (ubicaciones de almacenamiento)',
+    sectionDescription:
+      'Vincule sensores de temperatura, humedad o batería de Home Assistant a sus ubicaciones de almacenamiento para mostrar lecturas en vivo en la tarjeta de filamento y en la tabla. Solo se permite un sensor por categoría y ubicación.',
+    add: 'Añadir sensor',
+    addTitle: 'Añadir sensor de Home Assistant',
+    editTitle: 'Editar sensor de Home Assistant',
+    empty: 'Aún no hay sensores. Vincula un sensor de temperatura o humedad de Home Assistant para mostrarlo en la tarjeta del filamento.',
+    unknownLocation: 'Ubicación desconocida',
+    location: 'Ubicación de almacenamiento',
+    showOnCard: 'Mostrar en la tarjeta del filamento',
+    overwriteConfirm: {
+      title: '¿Reemplazar el sensor existente?',
+      messageTemperature: '"{{location}}" ya tiene un sensor de temperatura vinculado: {{name}}. Si continúas, se reemplazará.',
+      messageHumidity: '"{{location}}" ya tiene un sensor de humedad vinculado: {{name}}. Si continúas, se reemplazará.',
+      messageBattery: '"{{location}}" ya tiene un sensor de batería vinculado: {{name}}. Si continúas, se reemplazará.',
+    },
+    autoAdd: {
+      confirmTitle: '¿Añadir también los otros sensores?',
+      confirmMessage: 'Bambuddy también encontró otros sensores para esta ubicación. Elige cuáles añadir también.',
+      added: 'También añadido: {{names}}',
+      noneFound: 'No se encontraron sensores de temperatura, humedad o batería coincidentes para esta ubicación.',
+      onlyThisOne: 'Solo este',
+    },
+    options: {
+      buttonLabel: 'Opciones de sensores',
+      title: 'Opciones de sensores de ubicación',
+      description: 'Estos valores se usan cuando un sensor de temperatura, humedad o batería se vincula automáticamente junto con el primer sensor de una ubicación.',
+      generalSettings: 'Ajustes generales',
+      pollInterval: 'Intervalo de actualización (segundos)',
+      pollIntervalHint: 'Con qué frecuencia Bambuddy consulta Home Assistant y actualiza los valores de los sensores en pantalla. Mínimo 60 segundos.',
+      colorizeValues: 'Colorear los valores de los sensores según sus umbrales de alerta',
+      aboveColor: 'Color al superar el umbral',
+      belowColor: 'Color al estar por debajo del umbral',
+      optimalColor: 'Color para el valor óptimo',
+      colors: {
+        red: 'Rojo',
+        orange: 'Naranja',
+        yellow: 'Amarillo',
+        green: 'Verde',
+        blue: 'Azul',
+        purple: 'Morado',
+        pink: 'Rosa',
+      },
+      saved: 'Opciones guardadas',
+      saveFailed: 'No se pudieron guardar las opciones',
+      reset: 'Restablecer',
+      resetConfirm: {
+        title: '¿Restablecer todos los sensores de ubicación existentes?',
+        message:
+          'Esto sobrescribe los umbrales de alerta, las notificaciones y la visibilidad en la tarjeta de todos los sensores de temperatura, humedad y batería de ubicaciones existentes con los valores configurados arriba, y restaura el nombre de cada sensor a su nombre descriptivo de Home Assistant; los sensores de impresora no se ven afectados. Esto no se puede deshacer. Dependiendo de cuántos sensores tenga, esto puede tardar un momento.',
+      },
+      resetDone: 'Aplicado a {{count}} sensores de ubicación',
+      resetFailed: 'No se pudieron restablecer los sensores de ubicación',
+    },
+    deleteAllConfirm: {
+      title: '¿Eliminar todos los sensores?',
+      message: 'Esto elimina los {{count}} sensores vinculados a "{{location}}". Esta acción no se puede deshacer.',
+    },
+    currentlyBound: 'Actualmente vinculado: {{entity}}',
+    error: {
+      locationRequired: 'Elige una ubicación de almacenamiento',
+    },
+    toast: {
+      created: 'Sensor añadido',
+      updated: 'Sensor guardado',
+      deleted: 'Sensor eliminado',
+    },
+  },
   smartPlugs: {
     offline: 'Desconectado',
     admin: 'Administración',
@@ -6106,8 +6181,10 @@ export default {
     notificationEvents: 'Eventos de notificación',
     progressPercent: '(25%, 50%, 75%)',
     bedCooledAfterPrint: '(después de completar la impresión)',
-    haSensorAlert: 'Alerta de sensor',
+    haSensorAlert: 'Alerta de sensor de impresora',
     haSensorAlertDescription: '(un sensor de Home Assistant vinculado requiere atención)',
+    locationHaSensorAlert: 'Alerta de sensor de ubicación',
+    locationHaSensorAlertDescription: '(un sensor de Home Assistant vinculado a una ubicación de almacenamiento requiere atención)',
     // Per-event ntfy priority (#990)
     eventPriority: {
       sectionTitle: 'Prioridad de ntfy',

+ 79 - 2
frontend/src/i18n/locales/fr.ts

@@ -1846,6 +1846,7 @@ export default {
     tabs: {
       general: 'Général',
       smartPlugs: 'Prises connectées',
+      sensors: 'Capteurs',
       notifications: 'Notifications',
       queue: 'Flux de travail',
       queueDispatch: 'File & Distribution',
@@ -4490,6 +4491,7 @@ export default {
     edit: 'Modifier l\'emplacement',
     name: 'Nom',
     spools: 'Bobines',
+    sensors: 'Capteurs',
     empty: 'Aucun emplacement de stockage. Créez votre première étagère ou tiroir.',
     manage: 'Emplacements',
     createPlaceholder: 'ex. Étagère A, Tiroir 1',
@@ -4502,6 +4504,7 @@ export default {
     deleteBlocked: 'Retirez d\'abord toutes les bobines de cet emplacement',
     confirmDelete: 'Supprimer « {{name}} » ?',
     confirmDeleteMessage: 'Cet emplacement sera retiré du catalogue. Déplacez d\'abord les bobines.',
+    confirmDeleteMessageWithSensors: "Cet emplacement sera retiré du catalogue. Déplacez d'abord les bobines. Les capteurs connectés seront également supprimés.",
   },
 
   // Inventory
@@ -4668,6 +4671,9 @@ export default {
     spoolName: 'Bobine',
     costPerKg: 'Coût par kg',
     storageLocation: 'Emplacement de stockage',
+    temperature: 'Température',
+    humidity: 'Humidité',
+    battery: 'Batterie',
     storageLocationPlaceholder: 'ex. Étagère A, Tiroir 1',
     openInInventory: "Ouvrir dans l'inventaire",
     measuredWeightError: 'Le poids mesuré doit être entre {{min}}g et {{max}}g.',
@@ -5736,7 +5742,7 @@ export default {
       on: 'Activé',
       off: 'Désactivé',
     },
-    sectionTitle: 'Capteurs Home Assistant',
+    sectionTitle: 'Capteurs Home Assistant (imprimantes)',
     add: 'Ajouter un capteur',
     addTitle: 'Ajouter un capteur Home Assistant',
     editTitle: 'Modifier le capteur Home Assistant',
@@ -5771,6 +5777,75 @@ export default {
       alertRequired: 'Définissez d\'abord une condition d\'alerte',
     },
   },
+  locationHaSensors: {
+    sectionTitle: 'Capteurs Home Assistant (emplacements de stockage)',
+    sectionDescription:
+      "Associez des capteurs de température, d'humidité ou de batterie Home Assistant à vos emplacements de stockage pour afficher les relevés en direct sur la fiche du filament et dans le tableau. Un seul capteur par catégorie est autorisé par emplacement.",
+    add: 'Ajouter un capteur',
+    addTitle: 'Ajouter un capteur Home Assistant',
+    editTitle: 'Modifier le capteur Home Assistant',
+    empty: 'Aucun capteur pour l\'instant. Liez un capteur de température ou d\'humidité depuis Home Assistant pour l\'afficher sur la carte filament.',
+    unknownLocation: 'Emplacement inconnu',
+    location: 'Emplacement de stockage',
+    showOnCard: 'Afficher sur la carte filament',
+    overwriteConfirm: {
+      title: 'Remplacer le capteur existant ?',
+      messageTemperature: '« {{location}} » a déjà un capteur de température lié : {{name}}. Continuer le remplacera.',
+      messageHumidity: '« {{location}} » a déjà un capteur d\'humidité lié : {{name}}. Continuer le remplacera.',
+      messageBattery: '« {{location}} » a déjà un capteur de batterie lié : {{name}}. Continuer le remplacera.',
+    },
+    autoAdd: {
+      confirmTitle: 'Ajouter aussi les autres capteurs ?',
+      confirmMessage: "Bambuddy a également trouvé d'autres capteurs pour cet emplacement. Choisissez ceux à ajouter aussi.",
+      added: 'Également ajouté : {{names}}',
+      noneFound: 'Aucun capteur de température, d\'humidité ou de batterie correspondant trouvé pour cet emplacement.',
+      onlyThisOne: 'Seulement celui-ci',
+    },
+    options: {
+      buttonLabel: 'Options des capteurs',
+      title: 'Options des capteurs d\'emplacement',
+      description: 'Ces valeurs sont utilisées lorsqu\'un capteur de température, d\'humidité ou de batterie est lié automatiquement avec le premier capteur d\'un emplacement.',
+      generalSettings: "Paramètres généraux",
+      pollInterval: "Intervalle de mise à jour (secondes)",
+      pollIntervalHint: "Fréquence à laquelle Bambuddy interroge Home Assistant et actualise les valeurs des capteurs à l'écran. Minimum 60 secondes.",
+      colorizeValues: "Colorer les valeurs des capteurs selon leurs seuils d'alerte",
+      aboveColor: 'Couleur au-dessus du seuil',
+      belowColor: 'Couleur en dessous du seuil',
+      optimalColor: 'Couleur pour la valeur optimale',
+      colors: {
+        red: 'Rouge',
+        orange: 'Orange',
+        yellow: 'Jaune',
+        green: 'Vert',
+        blue: 'Bleu',
+        purple: 'Violet',
+        pink: 'Rose',
+      },
+      saved: 'Options enregistrées',
+      saveFailed: 'Échec de l\'enregistrement des options',
+      reset: 'Réinitialiser',
+      resetConfirm: {
+        title: 'Réinitialiser tous les capteurs d\'emplacement existants ?',
+        message:
+          'Cela écrase les seuils d\'alerte, les notifications et la visibilité sur la carte de tous les capteurs de température, d\'humidité et de batterie d\'emplacement existants avec les valeurs configurées ci-dessus, et restaure le nom de chaque capteur avec son nom convivial Home Assistant — les capteurs d\'imprimante ne sont pas concernés. Cette action est irréversible. Selon le nombre de capteurs, cela peut prendre un moment.',
+      },
+      resetDone: 'Appliqué à {{count}} capteurs d\'emplacement',
+      resetFailed: 'Échec de la réinitialisation des capteurs d\'emplacement',
+    },
+    deleteAllConfirm: {
+      title: 'Supprimer tous les capteurs ?',
+      message: 'Cela supprime les {{count}} capteurs liés à « {{location}} ». Cette action est irréversible.',
+    },
+    currentlyBound: 'Actuellement lié : {{entity}}',
+    error: {
+      locationRequired: 'Choisissez un emplacement de stockage',
+    },
+    toast: {
+      created: 'Capteur ajouté',
+      updated: 'Capteur enregistré',
+      deleted: 'Capteur supprimé',
+    },
+  },
   smartPlugs: {
     offline: 'Hors ligne',
     admin: 'Administrateur',
@@ -6088,8 +6163,10 @@ export default {
     notificationEvents: 'Événements de notification',
     progressPercent: '(25 %, 50 %, 75 %)',
     bedCooledAfterPrint: '(après la fin de l\'impression)',
-    haSensorAlert: 'Alerte de capteur',
+    haSensorAlert: "Alerte de capteur d'imprimante",
     haSensorAlertDescription: '(un capteur Home Assistant lié nécessite votre attention)',
+    locationHaSensorAlert: 'Alerte de capteur d\'emplacement',
+    locationHaSensorAlertDescription: '(un capteur Home Assistant lié à un emplacement de stockage nécessite votre attention)',
     // Per-event ntfy priority (#990)
     eventPriority: {
       sectionTitle: 'Priorité ntfy',

+ 79 - 2
frontend/src/i18n/locales/it.ts

@@ -1846,6 +1846,7 @@ export default {
     tabs: {
       general: 'Generale',
       smartPlugs: 'Prese smart',
+      sensors: 'Sensori',
       notifications: 'Notifiche',
       queue: 'Flusso',
       queueDispatch: 'Coda e Dispatch',
@@ -4489,6 +4490,7 @@ export default {
     edit: 'Modifica ubicazione',
     name: 'Nome',
     spools: 'Bobine',
+    sensors: 'Sensori',
     empty: 'Nessuna ubicazione di stoccaggio. Crea il tuo primo scaffale o cassetto.',
     manage: 'Ubicazioni',
     createPlaceholder: 'es. Scaffale A, Cassetto 1',
@@ -4501,6 +4503,7 @@ export default {
     deleteBlocked: 'Rimuovi prima tutte le bobine da questa ubicazione',
     confirmDelete: 'Eliminare «{{name}}»?',
     confirmDeleteMessage: 'Questa ubicazione verrà rimossa dal catalogo. Sposta prima le bobine.',
+    confirmDeleteMessageWithSensors: 'Questa ubicazione verrà rimossa dal catalogo. Sposta prima le bobine. Verranno eliminati anche i sensori collegati.',
   },
 
   // Inventory
@@ -4667,6 +4670,9 @@ export default {
     spoolName: 'Bobina',
     costPerKg: 'Costo per kg',
     storageLocation: 'Posizione di archiviazione',
+    temperature: 'Temperatura',
+    humidity: 'Umidità',
+    battery: 'Batteria',
     storageLocationPlaceholder: 'es. Scaffale A, Cassetto 1',
     openInInventory: "Apri nell'inventario",
     measuredWeightError: 'Il peso misurato deve essere compreso tra {{min}}g e {{max}}g.',
@@ -5735,7 +5741,7 @@ export default {
       on: 'Acceso',
       off: 'Spento',
     },
-    sectionTitle: 'Sensori Home Assistant',
+    sectionTitle: 'Sensori Home Assistant (stampanti)',
     add: 'Aggiungi sensore',
     addTitle: 'Aggiungi sensore Home Assistant',
     editTitle: 'Modifica sensore Home Assistant',
@@ -5770,6 +5776,75 @@ export default {
       alertRequired: 'Imposta prima una condizione di allarme',
     },
   },
+  locationHaSensors: {
+    sectionTitle: 'Sensori Home Assistant (posizioni di archiviazione)',
+    sectionDescription:
+      'Collega sensori di temperatura, umidità o batteria di Home Assistant alle tue posizioni di archiviazione per mostrare le letture in tempo reale sulla scheda del filamento e nella tabella. È consentito un solo sensore per categoria per posizione.',
+    add: 'Aggiungi sensore',
+    addTitle: 'Aggiungi sensore Home Assistant',
+    editTitle: 'Modifica sensore Home Assistant',
+    empty: 'Nessun sensore. Collega un sensore di temperatura o umidità da Home Assistant per mostrarlo sulla scheda del filamento.',
+    unknownLocation: 'Posizione sconosciuta',
+    location: 'Posizione di archiviazione',
+    showOnCard: 'Mostra sulla scheda del filamento',
+    overwriteConfirm: {
+      title: 'Sostituire il sensore esistente?',
+      messageTemperature: '"{{location}}" ha già un sensore di temperatura collegato: {{name}}. Continuando verrà sostituito.',
+      messageHumidity: '"{{location}}" ha già un sensore di umidità collegato: {{name}}. Continuando verrà sostituito.',
+      messageBattery: '"{{location}}" ha già un sensore di batteria collegato: {{name}}. Continuando verrà sostituito.',
+    },
+    autoAdd: {
+      confirmTitle: 'Aggiungere anche gli altri sensori?',
+      confirmMessage: 'Bambuddy ha trovato anche altri sensori per questa posizione. Scegli quali aggiungere.',
+      added: 'Aggiunto anche: {{names}}',
+      noneFound: 'Nessun sensore di temperatura, umidità o batteria corrispondente trovato per questa posizione.',
+      onlyThisOne: 'Solo questo',
+    },
+    options: {
+      buttonLabel: 'Opzioni sensori',
+      title: 'Opzioni sensori di posizione',
+      description: 'Questi valori vengono usati quando un sensore di temperatura, umidità o batteria viene collegato automaticamente insieme al primo sensore di una posizione.',
+      generalSettings: 'Impostazioni generali',
+      pollInterval: 'Intervallo di aggiornamento (secondi)',
+      pollIntervalHint: 'Con quale frequenza Bambuddy interroga Home Assistant e aggiorna i valori dei sensori a schermo. Minimo 60 secondi.',
+      colorizeValues: 'Colora i valori dei sensori in base alle relative soglie di allarme',
+      aboveColor: 'Colore sopra la soglia',
+      belowColor: 'Colore sotto la soglia',
+      optimalColor: 'Colore per il valore ottimale',
+      colors: {
+        red: 'Rosso',
+        orange: 'Arancione',
+        yellow: 'Giallo',
+        green: 'Verde',
+        blue: 'Blu',
+        purple: 'Viola',
+        pink: 'Rosa',
+      },
+      saved: 'Opzioni salvate',
+      saveFailed: 'Impossibile salvare le opzioni',
+      reset: 'Ripristina',
+      resetConfirm: {
+        title: 'Reimpostare tutti i sensori di posizione esistenti?',
+        message:
+          'Questo sovrascrive le soglie di allarme, le notifiche e la visibilità sulla scheda di tutti i sensori di temperatura, umidità e batteria di posizione esistenti con i valori configurati sopra, e ripristina il nome di ciascun sensore al suo nome descrittivo di Home Assistant: i sensori della stampante non sono interessati. Questa azione non può essere annullata. A seconda del numero di sensori, questa operazione potrebbe richiedere un momento.',
+      },
+      resetDone: 'Applicato a {{count}} sensori di posizione',
+      resetFailed: 'Impossibile reimpostare i sensori di posizione',
+    },
+    deleteAllConfirm: {
+      title: 'Eliminare tutti i sensori?',
+      message: 'Questo rimuove tutti i {{count}} sensori collegati a "{{location}}". Questa azione non può essere annullata.',
+    },
+    currentlyBound: 'Attualmente collegato: {{entity}}',
+    error: {
+      locationRequired: 'Scegli una posizione di archiviazione',
+    },
+    toast: {
+      created: 'Sensore aggiunto',
+      updated: 'Sensore salvato',
+      deleted: 'Sensore rimosso',
+    },
+  },
   smartPlugs: {
     offline: 'Offline',
     admin: 'Amministrazione',
@@ -6087,8 +6162,10 @@ export default {
     notificationEvents: 'Eventi di notifica',
     progressPercent: '(25%, 50%, 75%)',
     bedCooledAfterPrint: '(dopo il completamento della stampa)',
-    haSensorAlert: 'Avviso sensore',
+    haSensorAlert: 'Avviso sensore stampante',
     haSensorAlertDescription: '(un sensore Home Assistant collegato richiede attenzione)',
+    locationHaSensorAlert: 'Avviso sensore di posizione',
+    locationHaSensorAlertDescription: '(un sensore Home Assistant collegato a una posizione di archiviazione richiede attenzione)',
     // Per-event ntfy priority (#990)
     eventPriority: {
       sectionTitle: 'Priorità ntfy',

+ 79 - 2
frontend/src/i18n/locales/ja.ts

@@ -1845,6 +1845,7 @@ export default {
     tabs: {
       general: '一般',
       smartPlugs: 'スマートプラグ',
+      sensors: 'センサー',
       notifications: '通知',
       queue: 'ワークフロー',
       queueDispatch: 'キューとディスパッチ',
@@ -4501,6 +4502,7 @@ export default {
     edit: '場所を編集',
     name: '名前',
     spools: 'スプール',
+    sensors: 'センサー',
     empty: '保管場所がありません。最初の棚または引き出しを作成してください。',
     manage: '保管場所',
     createPlaceholder: '例: 棚A、引き出し1',
@@ -4513,6 +4515,7 @@ export default {
     deleteBlocked: '削除前にこの場所のスプールをすべて移動してください',
     confirmDelete: '「{{name}}」を削除しますか?',
     confirmDeleteMessage: 'この場所はカタログから削除されます。先にスプールを移動してください。',
+    confirmDeleteMessageWithSensors: 'この場所はカタログから削除されます。先にスプールを移動してください。接続されているセンサーも削除されます。',
   },
 
   // Inventory
@@ -4679,6 +4682,9 @@ export default {
     spoolName: 'スプール',
     costPerKg: 'kgあたりのコスト',
     storageLocation: '保管場所',
+    temperature: '温度',
+    humidity: '湿度',
+    battery: 'バッテリー',
     storageLocationPlaceholder: '例:棚A、引き出し1',
     openInInventory: 'インベントリで開く',
     measuredWeightError: '計測重量は{{min}}gから{{max}}gの間で入力してください。',
@@ -5747,7 +5753,7 @@ export default {
       on: 'オン',
       off: 'オフ',
     },
-    sectionTitle: 'Home Assistant センサー',
+    sectionTitle: 'Home Assistant センサー(プリンター)',
     add: 'センサーを追加',
     addTitle: 'Home Assistant センサーを追加',
     editTitle: 'Home Assistant センサーを編集',
@@ -5782,6 +5788,75 @@ export default {
       alertRequired: '先にアラート条件を設定してください',
     },
   },
+  locationHaSensors: {
+    sectionTitle: 'Home Assistant センサー(保管場所)',
+    sectionDescription:
+      'Home Assistant の温度・湿度・バッテリーセンサーを保管場所に紐づけると、フィラメントカードと一覧表にリアルタイムの値が表示されます。1つの保管場所につき、各カテゴリーで登録できるセンサーは1つだけです。',
+    add: 'センサーを追加',
+    addTitle: 'Home Assistant センサーを追加',
+    editTitle: 'Home Assistant センサーを編集',
+    empty: 'センサーがまだありません。Home Assistant の温度・湿度センサーを連携すると、フィラメントカードに表示されます。',
+    unknownLocation: '不明な保管場所',
+    location: '保管場所',
+    showOnCard: 'フィラメントカードに表示',
+    overwriteConfirm: {
+      title: '既存のセンサーを置き換えますか?',
+      messageTemperature: '「{{location}}」には既に温度センサーが連携されています: {{name}}。続行するとこれが置き換えられます。',
+      messageHumidity: '「{{location}}」には既に湿度センサーが連携されています: {{name}}。続行するとこれが置き換えられます。',
+      messageBattery: '「{{location}}」には既にバッテリーセンサーが連携されています: {{name}}。続行するとこれが置き換えられます。',
+    },
+    autoAdd: {
+      confirmTitle: '他のセンサーも追加しますか?',
+      confirmMessage: 'Bambuddy がこの保管場所の他のセンサーも見つけました。追加するものを選んでください。',
+      added: '追加で連携しました: {{names}}',
+      noneFound: 'この保管場所に一致する温度・湿度・バッテリーセンサーが見つかりませんでした。',
+      onlyThisOne: 'これだけ',
+    },
+    options: {
+      buttonLabel: 'センサーオプション',
+      title: '保管場所センサーのオプション',
+      description: 'この値は、保管場所の最初のセンサーと一緒に温度・湿度・バッテリーセンサーが自動的に連携される際に使用されます。',
+      generalSettings: '一般設定',
+      pollInterval: '更新間隔(秒)',
+      pollIntervalHint: 'Bambuddyがホームアシスタントに問い合わせて画面上のセンサー値を更新する頻度です。最小60秒。',
+      colorizeValues: 'アラートしきい値に応じてセンサー値を色分け表示',
+      aboveColor: 'しきい値超過時の色',
+      belowColor: 'しきい値未満時の色',
+      optimalColor: '最適値の色',
+      colors: {
+        red: '赤',
+        orange: 'オレンジ',
+        yellow: '黄',
+        green: '緑',
+        blue: '青',
+        purple: '紫',
+        pink: 'ピンク',
+      },
+      saved: 'オプションを保存しました',
+      saveFailed: 'オプションの保存に失敗しました',
+      reset: 'リセット',
+      resetConfirm: {
+        title: '既存のすべての保管場所センサーをリセットしますか?',
+        message:
+          'これにより、既存のすべての保管場所の温度・湿度・バッテリーセンサーのアラートしきい値、通知、カード表示設定が上記の値で上書きされ、各センサーの名前も Home Assistant のフレンドリーネームに戻されます(プリンターのセンサーには影響しません)。この操作は元に戻せません。センサーの数によっては、少し時間がかかる場合があります。',
+      },
+      resetDone: '{{count}} 件の保管場所センサーに適用しました',
+      resetFailed: '保管場所センサーのリセットに失敗しました',
+    },
+    deleteAllConfirm: {
+      title: 'すべてのセンサーを削除しますか?',
+      message: '「{{location}}」に連携されているすべてのセンサー({{count}}個)を削除します。この操作は元に戻せません。',
+    },
+    currentlyBound: '現在のバインド先: {{entity}}',
+    error: {
+      locationRequired: '保管場所を選択してください',
+    },
+    toast: {
+      created: 'センサーを追加しました',
+      updated: 'センサーを保存しました',
+      deleted: 'センサーを削除しました',
+    },
+  },
   smartPlugs: {
     offline: 'オフライン',
     admin: '管理',
@@ -6099,8 +6174,10 @@ export default {
     notificationEvents: '通知イベント',
     progressPercent: '(25%、50%、75%)',
     bedCooledAfterPrint: '(印刷完了後)',
-    haSensorAlert: 'センサーアラート',
+    haSensorAlert: 'プリンターセンサーアラート',
     haSensorAlertDescription: '(連携した Home Assistant センサーに注意が必要です)',
+    locationHaSensorAlert: '保管場所センサーアラート',
+    locationHaSensorAlertDescription: '(保管場所に連携した Home Assistant センサーに注意が必要です)',
     // Per-event ntfy priority (#990)
     eventPriority: {
       sectionTitle: 'ntfy 優先度',

+ 79 - 2
frontend/src/i18n/locales/ko.ts

@@ -1759,6 +1759,7 @@ export default {
     tabs: {
       general: '일반',
       smartPlugs: '스마트 플러그',
+      sensors: '센서',
       notifications: '알림',
       queue: '워크플로우',
       queueDispatch: '큐 및 디스패치',
@@ -4290,6 +4291,7 @@ export default {
     edit: '위치 편집',
     name: '이름',
     spools: '스풀',
+    sensors: '센서',
     empty: '아직 보관 위치가 없습니다. 첫 번째 선반이나 서랍을 만드세요.',
     manage: '위치',
     createPlaceholder: '예: 선반 A, 서랍 1',
@@ -4302,6 +4304,7 @@ export default {
     deleteBlocked: '삭제하기 전에 이 위치의 모든 스풀을 옮기세요',
     confirmDelete: '"{{name}}"을(를) 삭제하시겠습니까?',
     confirmDeleteMessage: '이 위치가 카탈로그에서 제거됩니다. 스풀을 먼저 옮겨야 합니다.',
+    confirmDeleteMessageWithSensors: '이 위치가 카탈로그에서 제거됩니다. 스풀을 먼저 옮겨야 합니다. 연결된 센서도 함께 삭제됩니다.',
   },
 
   inventory: {
@@ -4466,6 +4469,9 @@ export default {
     spoolName: '스풀',
     costPerKg: 'kg당 비용',
     storageLocation: '보관 위치',
+    temperature: '온도',
+    humidity: '습도',
+    battery: '배터리',
     storageLocationPlaceholder: '예: 선반 A, 서랍 1',
     openInInventory: '재고에서 열기',
     measuredWeightError: '측정된 무게는 {{min}}g과 {{max}}g 사이여야 합니다.',
@@ -5476,7 +5482,7 @@ export default {
       on: '켜짐',
       off: '꺼짐',
     },
-    sectionTitle: 'Home Assistant 센서',
+    sectionTitle: 'Home Assistant 센서 (프린터)',
     add: '센서 추가',
     addTitle: 'Home Assistant 센서 추가',
     editTitle: 'Home Assistant 센서 편집',
@@ -5511,6 +5517,75 @@ export default {
       alertRequired: '먼저 경고 조건을 설정하세요',
     },
   },
+  locationHaSensors: {
+    sectionTitle: 'Home Assistant 센서 (보관 위치)',
+    sectionDescription:
+      'Home Assistant의 온도, 습도, 배터리 센서를 보관 위치에 연결하면 필라멘트 카드와 표에 실시간 값이 표시됩니다. 위치당 카테고리별로 센서를 하나만 등록할 수 있습니다.',
+    add: '센서 추가',
+    addTitle: 'Home Assistant 센서 추가',
+    editTitle: 'Home Assistant 센서 편집',
+    empty: '아직 센서가 없습니다. Home Assistant의 온도나 습도 센서를 연결하면 필라멘트 카드에 표시됩니다.',
+    unknownLocation: '알 수 없는 위치',
+    location: '보관 위치',
+    showOnCard: '필라멘트 카드에 표시',
+    overwriteConfirm: {
+      title: '기존 센서를 교체하시겠습니까?',
+      messageTemperature: '"{{location}}"에는 이미 연결된 온도 센서가 있습니다: {{name}}. 계속하면 교체됩니다.',
+      messageHumidity: '"{{location}}"에는 이미 연결된 습도 센서가 있습니다: {{name}}. 계속하면 교체됩니다.',
+      messageBattery: '"{{location}}"에는 이미 연결된 배터리 센서가 있습니다: {{name}}. 계속하면 교체됩니다.',
+    },
+    autoAdd: {
+      confirmTitle: '다른 센서도 추가할까요?',
+      confirmMessage: 'Bambuddy가 이 위치의 다른 센서도 찾았습니다. 추가할 항목을 선택하세요.',
+      added: '다음도 추가됨: {{names}}',
+      noneFound: '이 위치에 일치하는 온도, 습도 또는 배터리 센서를 찾을 수 없습니다.',
+      onlyThisOne: '이것만',
+    },
+    options: {
+      buttonLabel: '센서 옵션',
+      title: '위치 센서 옵션',
+      description: '이 값은 위치의 첫 번째 센서와 함께 온도, 습도 또는 배터리 센서가 자동으로 연결될 때 사용됩니다.',
+      generalSettings: '일반 설정',
+      pollInterval: '업데이트 간격(초)',
+      pollIntervalHint: 'Bambuddy가 Home Assistant를 폴링하여 화면의 센서 값을 갱신하는 주기입니다. 최소 60초.',
+      colorizeValues: '경보 임계값에 따라 센서 값에 색상 표시',
+      aboveColor: '임계값 초과 색상',
+      belowColor: '임계값 미만 색상',
+      optimalColor: '최적값 색상',
+      colors: {
+        red: '빨강',
+        orange: '주황',
+        yellow: '노랑',
+        green: '초록',
+        blue: '파랑',
+        purple: '보라',
+        pink: '분홍',
+      },
+      saved: '옵션이 저장되었습니다',
+      saveFailed: '옵션 저장에 실패했습니다',
+      reset: '초기화',
+      resetConfirm: {
+        title: '기존 위치 센서를 모두 재설정하시겠습니까?',
+        message:
+          '위에서 설정한 값으로 기존의 모든 보관 위치 온도, 습도, 배터리 센서의 경보 임계값, 알림, 카드 표시 여부를 덮어쓰고, 각 센서의 이름을 Home Assistant의 친숙한 이름으로 복원합니다. 프린터 센서는 영향을 받지 않습니다. 이 작업은 되돌릴 수 없습니다. 센서 수에 따라 시간이 다소 걸릴 수 있습니다.',
+      },
+      resetDone: '{{count}}개의 위치 센서에 적용되었습니다',
+      resetFailed: '위치 센서 재설정에 실패했습니다',
+    },
+    deleteAllConfirm: {
+      title: '모든 센서를 삭제하시겠습니까?',
+      message: '"{{location}}"에 연결된 모든 센서({{count}}개)가 삭제됩니다. 이 작업은 되돌릴 수 없습니다.',
+    },
+    currentlyBound: '현재 연결됨: {{entity}}',
+    error: {
+      locationRequired: '보관 위치를 선택하세요',
+    },
+    toast: {
+      created: '센서를 추가했습니다',
+      updated: '센서를 저장했습니다',
+      deleted: '센서를 삭제했습니다',
+    },
+  },
   smartPlugs: {
     offline: '오프라인',
     admin: '관리자',
@@ -5811,8 +5886,10 @@ export default {
     notificationEvents: '알림 이벤트',
     progressPercent: '(25%, 50%, 75%)',
     bedCooledAfterPrint: '(인쇄 완료 후)',
-    haSensorAlert: '센서 경고',
+    haSensorAlert: '프린터 센서 경고',
     haSensorAlertDescription: '(연결된 Home Assistant 센서에 주의가 필요합니다)',
+    locationHaSensorAlert: '보관 위치 센서 경고',
+    locationHaSensorAlertDescription: '(보관 위치에 연결된 Home Assistant 센서에 주의가 필요합니다)',
     eventPriority: {
       sectionTitle: 'ntfy 우선순위',
       helpNtfy: '각 활성화된 이벤트에 대한 우선순위를 선택하세요. ntfy는 이를 사용하여 알림을 에스컬레이션합니다(소리, 가시성, 푸시 동작). 여기서 설정되지 않은 수준은 ntfy 서버 기본값을 사용합니다.',

+ 79 - 2
frontend/src/i18n/locales/pt-BR.ts

@@ -1846,6 +1846,7 @@ export default {
     tabs: {
       general: 'Geral',
       smartPlugs: 'Tomadas Inteligentes',
+      sensors: 'Sensores',
       notifications: 'Notificações',
       queue: 'Fluxo',
       queueDispatch: 'Fila e Dispatch',
@@ -4489,6 +4490,7 @@ export default {
     edit: 'Editar local',
     name: 'Nome',
     spools: 'Bobinas',
+    sensors: 'Sensores',
     empty: 'Nenhum local de armazenamento. Crie sua primeira prateleira ou gaveta.',
     manage: 'Locais',
     createPlaceholder: 'ex. Prateleira A, Gaveta 1',
@@ -4501,6 +4503,7 @@ export default {
     deleteBlocked: 'Remova todas as bobinas deste local antes de excluir',
     confirmDelete: 'Excluir «{{name}}»?',
     confirmDeleteMessage: 'Este local será removido do catálogo. Mova as bobinas primeiro.',
+    confirmDeleteMessageWithSensors: 'Este local será removido do catálogo. Mova as bobinas primeiro. Os sensores conectados também serão excluídos.',
   },
 
   // Inventory
@@ -4667,6 +4670,9 @@ export default {
     spoolName: 'Bobina',
     costPerKg: 'Custo por kg',
     storageLocation: 'Local de armazenamento',
+    temperature: 'Temperatura',
+    humidity: 'Umidade',
+    battery: 'Bateria',
     storageLocationPlaceholder: 'ex. Prateleira A, Gaveta 1',
     openInInventory: 'Abrir no inventário',
     measuredWeightError: 'O peso medido deve estar entre {{min}}g e {{max}}g.',
@@ -5735,7 +5741,7 @@ export default {
       on: 'Ligado',
       off: 'Desligado',
     },
-    sectionTitle: 'Sensores do Home Assistant',
+    sectionTitle: 'Sensores do Home Assistant (impressoras)',
     add: 'Adicionar sensor',
     addTitle: 'Adicionar sensor do Home Assistant',
     editTitle: 'Editar sensor do Home Assistant',
@@ -5770,6 +5776,75 @@ export default {
       alertRequired: 'Defina primeiro uma condição de alerta',
     },
   },
+  locationHaSensors: {
+    sectionTitle: 'Sensores do Home Assistant (locais de armazenamento)',
+    sectionDescription:
+      'Vincule sensores de temperatura, umidade ou bateria do Home Assistant aos seus locais de armazenamento para exibir leituras em tempo real no cartão do filamento e na tabela. É permitido apenas um sensor por categoria em cada local.',
+    add: 'Adicionar sensor',
+    addTitle: 'Adicionar sensor do Home Assistant',
+    editTitle: 'Editar sensor do Home Assistant',
+    empty: 'Nenhum sensor ainda. Vincule um sensor de temperatura ou umidade do Home Assistant para exibi-lo no cartão do filamento.',
+    unknownLocation: 'Local desconhecido',
+    location: 'Local de armazenamento',
+    showOnCard: 'Mostrar no cartão do filamento',
+    overwriteConfirm: {
+      title: 'Substituir o sensor existente?',
+      messageTemperature: '"{{location}}" já tem um sensor de temperatura vinculado: {{name}}. Continuar irá substituí-lo.',
+      messageHumidity: '"{{location}}" já tem um sensor de umidade vinculado: {{name}}. Continuar irá substituí-lo.',
+      messageBattery: '"{{location}}" já tem um sensor de bateria vinculado: {{name}}. Continuar irá substituí-lo.',
+    },
+    autoAdd: {
+      confirmTitle: 'Adicionar também os outros sensores?',
+      confirmMessage: 'O Bambuddy também encontrou outros sensores para este local. Escolha quais adicionar também.',
+      added: 'Também adicionado: {{names}}',
+      noneFound: 'Nenhum sensor de temperatura, umidade ou bateria correspondente foi encontrado para este local.',
+      onlyThisOne: 'Apenas este',
+    },
+    options: {
+      buttonLabel: 'Opções do sensor',
+      title: 'Opções do Sensor de Local',
+      description: 'Esses valores são usados quando um sensor de temperatura, umidade ou bateria é vinculado automaticamente junto com o primeiro sensor de um local.',
+      generalSettings: 'Configurações gerais',
+      pollInterval: 'Intervalo de atualização (segundos)',
+      pollIntervalHint: 'Com que frequência o Bambuddy consulta o Home Assistant e atualiza os valores dos sensores na tela. Mínimo de 60 segundos.',
+      colorizeValues: 'Colorir os valores dos sensores de acordo com seus limites de alerta',
+      aboveColor: 'Cor acima do limite',
+      belowColor: 'Cor abaixo do limite',
+      optimalColor: 'Cor para o valor ideal',
+      colors: {
+        red: 'Vermelho',
+        orange: 'Laranja',
+        yellow: 'Amarelo',
+        green: 'Verde',
+        blue: 'Azul',
+        purple: 'Roxo',
+        pink: 'Rosa',
+      },
+      saved: 'Opções salvas',
+      saveFailed: 'Falha ao salvar as opções',
+      reset: 'Redefinir',
+      resetConfirm: {
+        title: 'Redefinir todos os sensores de local existentes?',
+        message:
+          'Isso sobrescreve os limites de alerta, notificações e visibilidade no cartão de todos os sensores de temperatura, umidade e bateria de locais existentes com os valores configurados acima, e restaura o nome de cada sensor para o nome amigável do Home Assistant — os sensores de impressora não são afetados. Isso não pode ser desfeito. Dependendo da quantidade de sensores, isso pode levar um momento.',
+      },
+      resetDone: 'Aplicado a {{count}} sensores de local',
+      resetFailed: 'Falha ao redefinir os sensores de local',
+    },
+    deleteAllConfirm: {
+      title: 'Excluir todos os sensores?',
+      message: 'Isso remove todos os {{count}} sensores vinculados a "{{location}}". Esta ação não pode ser desfeita.',
+    },
+    currentlyBound: 'Atualmente vinculado: {{entity}}',
+    error: {
+      locationRequired: 'Escolha um local de armazenamento',
+    },
+    toast: {
+      created: 'Sensor adicionado',
+      updated: 'Sensor salvo',
+      deleted: 'Sensor removido',
+    },
+  },
   smartPlugs: {
     offline: 'Offline',
     admin: 'Administrador',
@@ -6087,8 +6162,10 @@ export default {
     notificationEvents: 'Eventos de Notificação',
     progressPercent: '(25%, 50%, 75%)',
     bedCooledAfterPrint: '(após conclusão da impressão)',
-    haSensorAlert: 'Alerta de sensor',
+    haSensorAlert: 'Alerta de sensor de impressora',
     haSensorAlertDescription: '(um sensor do Home Assistant vinculado precisa de atenção)',
+    locationHaSensorAlert: 'Alerta de sensor de local',
+    locationHaSensorAlertDescription: '(um sensor do Home Assistant vinculado a um local de armazenamento precisa de atenção)',
     // Per-event ntfy priority (#990)
     eventPriority: {
       sectionTitle: 'Prioridade ntfy',

+ 79 - 2
frontend/src/i18n/locales/ru.ts

@@ -1756,6 +1756,7 @@ export default {
     tabs: {
       general: "Общие",
       smartPlugs: "Умные розетки",
+      sensors: "Датчики",
       notifications: "Уведомления",
       queue: "Рабочий процесс",
       queueDispatch: "Очередь и отправка",
@@ -4280,6 +4281,7 @@ export default {
     edit: "Изменить место",
     name: "Название",
     spools: "Катушки",
+    sensors: "Датчики",
     empty: "Мест хранения пока нет. Создайте первую полку или ящик.",
     manage: "Места хранения",
     createPlaceholder: "например, Полка A или Ящик 1",
@@ -4292,6 +4294,7 @@ export default {
     deleteBlocked: "Перед удалением переместите из этого места все катушки",
     confirmDelete: "Удалить «{{name}}»?",
     confirmDeleteMessage: "Это место будет удалено из каталога. Сначала необходимо переместить все катушки.",
+    confirmDeleteMessageWithSensors: 'Это место будет удалено из каталога. Сначала необходимо переместить все катушки. Подключённые датчики также будут удалены.',
   },
   inventory: {
     title: "Учёт катушек",
@@ -4456,6 +4459,9 @@ export default {
     spoolName: "Катушка",
     costPerKg: "Стоимость за кг",
     storageLocation: "Место хранения",
+    temperature: "Температура",
+    humidity: "Влажность",
+    battery: "Батарея",
     storageLocationPlaceholder: "например, Полка A, Ящик 1",
     openInInventory: "Открыть в учёте",
     measuredWeightError: "Измеренная масса должна быть от {{min}} до {{max}} г.",
@@ -5464,7 +5470,7 @@ export default {
       on: "Вкл",
       off: "Выкл",
     },
-    sectionTitle: "Датчики Home Assistant",
+    sectionTitle: "Датчики Home Assistant (принтеры)",
     add: "Добавить датчик",
     addTitle: "Добавить датчик Home Assistant",
     editTitle: "Изменить датчик Home Assistant",
@@ -5499,6 +5505,75 @@ export default {
       alertRequired: "Сначала задайте условие оповещения",
     },
   },
+  locationHaSensors: {
+    sectionTitle: "Датчики Home Assistant (места хранения)",
+    sectionDescription:
+      "Привяжите датчики температуры, влажности или заряда батареи Home Assistant к местам хранения, чтобы показывать актуальные значения на карточке филамента и в таблице. Для каждого места хранения допускается только один датчик на категорию.",
+    add: "Добавить датчик",
+    addTitle: "Добавить датчик Home Assistant",
+    editTitle: "Изменить датчик Home Assistant",
+    empty: "Датчиков пока нет. Свяжите датчик температуры или влажности из Home Assistant, чтобы показать его на карточке филамента.",
+    unknownLocation: "Неизвестное место",
+    location: "Место хранения",
+    showOnCard: "Показывать на карточке филамента",
+    overwriteConfirm: {
+      title: "Заменить существующий датчик?",
+      messageTemperature: "У «{{location}}» уже привязан датчик температуры: {{name}}. Продолжение заменит его.",
+      messageHumidity: "У «{{location}}» уже привязан датчик влажности: {{name}}. Продолжение заменит его.",
+      messageBattery: "У «{{location}}» уже привязан датчик заряда батареи: {{name}}. Продолжение заменит его.",
+    },
+    autoAdd: {
+      confirmTitle: "Добавить также остальные датчики?",
+      confirmMessage: "Bambuddy также нашёл другие датчики для этого места. Выберите, какие добавить.",
+      added: "Также добавлено: {{names}}",
+      noneFound: "Подходящие датчики температуры, влажности или заряда батареи для этого места не найдены.",
+      onlyThisOne: "Только этот",
+    },
+    options: {
+      buttonLabel: "Настройки датчиков",
+      title: "Настройки датчиков места хранения",
+      description: "Эти значения используются, когда датчик температуры, влажности или заряда батареи привязывается автоматически вместе с первым датчиком места хранения.",
+      generalSettings: 'Общие настройки',
+      pollInterval: 'Интервал обновления (секунды)',
+      pollIntervalHint: 'Как часто Bambuddy опрашивает Home Assistant и обновляет значения датчиков на экране. Минимум 60 секунд.',
+      colorizeValues: 'Окрашивать значения датчиков в соответствии с их порогами тревоги',
+      aboveColor: 'Цвет при превышении порога',
+      belowColor: 'Цвет при значении ниже порога',
+      optimalColor: 'Цвет оптимального значения',
+      colors: {
+        red: 'Красный',
+        orange: 'Оранжевый',
+        yellow: 'Жёлтый',
+        green: 'Зелёный',
+        blue: 'Синий',
+        purple: 'Фиолетовый',
+        pink: 'Розовый',
+      },
+      saved: "Настройки сохранены",
+      saveFailed: "Не удалось сохранить настройки",
+      reset: "Сбросить",
+      resetConfirm: {
+        title: "Сбросить все существующие датчики мест хранения?",
+        message:
+          "Это перезапишет пороги тревоги, уведомления и видимость на карточке для всех существующих датчиков температуры, влажности и заряда батареи мест хранения значениями, настроенными выше, а также восстановит имя каждого датчика до его понятного имени в Home Assistant — датчики принтеров не затрагиваются. Это действие нельзя отменить. В зависимости от количества датчиков это может занять некоторое время.",
+      },
+      resetDone: "Применено к {{count}} датчикам мест хранения",
+      resetFailed: "Не удалось сбросить датчики мест хранения",
+    },
+    deleteAllConfirm: {
+      title: "Удалить все датчики?",
+      message: "Будут удалены все {{count}} датчиков, привязанных к «{{location}}». Это действие нельзя отменить.",
+    },
+    currentlyBound: "Сейчас привязан: {{entity}}",
+    error: {
+      locationRequired: "Выберите место хранения",
+    },
+    toast: {
+      created: "Датчик добавлен",
+      updated: "Датчик сохранён",
+      deleted: "Датчик удалён",
+    },
+  },
   smartPlugs: {
     offline: "Не в сети",
     admin: "Управление",
@@ -5798,8 +5873,10 @@ export default {
     notificationEvents: "События для уведомлений",
     progressPercent: "(25%, 50%, 75%)",
     bedCooledAfterPrint: "(после завершения печати)",
-    haSensorAlert: "Оповещение датчика",
+    haSensorAlert: "Оповещение датчика принтера",
     haSensorAlertDescription: "(связанный датчик Home Assistant требует внимания)",
+    locationHaSensorAlert: "Оповещение датчика места хранения",
+    locationHaSensorAlertDescription: "(датчик Home Assistant, связанный с местом хранения, требует внимания)",
     eventPriority: {
       sectionTitle: "Приоритет ntfy",
       helpNtfy: "Выберите приоритет для каждого включённого события. ntfy использует его для усиления оповещений: звука, видимости и поведения push-уведомлений. Для неуказанных событий используется приоритет по умолчанию сервера ntfy.",

+ 79 - 2
frontend/src/i18n/locales/tr.ts

@@ -1848,6 +1848,7 @@ export default {
     tabs: {
       general: 'Genel',
       smartPlugs: 'Akıllı Prizler',
+      sensors: 'Sensörler',
       notifications: 'Bildirimler',
       queue: 'İş Akışı',
       queueDispatch: 'Kuyruk ve Sevkıyat',
@@ -4490,6 +4491,7 @@ export default {
     edit: 'Konumu Düzenle',
     name: 'Ad',
     spools: 'Makaralar',
+    sensors: 'Sensörler',
     empty: 'Henüz depolama konumu yok. İlk rafınızı veya çekmecenizi oluşturun.',
     manage: 'Konumlar',
     createPlaceholder: 'örn. Raf A, Çekmece 1',
@@ -4502,6 +4504,7 @@ export default {
     deleteBlocked: 'Silmeden önce bu konumdaki tüm makaraları taşıyın',
     confirmDelete: '"{{name}}" silinsin mi?',
     confirmDeleteMessage: 'Bu konum kataloğdan kaldırılacak. Önce makaralar taşınmalıdır.',
+    confirmDeleteMessageWithSensors: 'Bu konum kataloğdan kaldırılacak. Önce makaralar taşınmalıdır. Bağlı sensörler de silinecek.',
   },
 
   // Envanter
@@ -4668,6 +4671,9 @@ export default {
     spoolName: 'Makara',
     costPerKg: 'kg başına maliyet',
     storageLocation: 'Depolama Konumu',
+    temperature: 'Sıcaklık',
+    humidity: 'Nem',
+    battery: 'Pil',
     storageLocationPlaceholder: 'örn. Raf A, Çekmece 1',
     openInInventory: 'Envanterde Aç',
     measuredWeightError: 'Ölçülen ağırlık {{min}}g ile {{max}}g arasında olmalı.',
@@ -5710,7 +5716,7 @@ export default {
       on: 'Açık',
       off: 'Kapalı',
     },
-    sectionTitle: 'Home Assistant Sensörleri',
+    sectionTitle: 'Home Assistant Sensörleri (Yazıcılar)',
     add: 'Sensör ekle',
     addTitle: 'Home Assistant sensörü ekle',
     editTitle: 'Home Assistant sensörünü düzenle',
@@ -5745,6 +5751,75 @@ export default {
       alertRequired: 'Önce bir uyarı koşulu belirleyin',
     },
   },
+  locationHaSensors: {
+    sectionTitle: 'Home Assistant Sensörleri (Depolama Konumları)',
+    sectionDescription:
+      'Filament kartında ve tabloda canlı değerleri göstermek için Home Assistant sıcaklık, nem veya pil sensörlerini depolama konumlarınıza bağlayın. Konum başına kategori başına yalnızca bir sensöre izin verilir.',
+    add: 'Sensör ekle',
+    addTitle: 'Home Assistant sensörü ekle',
+    editTitle: 'Home Assistant sensörünü düzenle',
+    empty: 'Henüz sensör yok. Filament kartında göstermek için Home Assistant\'tan bir sıcaklık veya nem sensörü bağlayın.',
+    unknownLocation: 'Bilinmeyen konum',
+    location: 'Depolama Konumu',
+    showOnCard: 'Filament kartında göster',
+    overwriteConfirm: {
+      title: 'Mevcut sensör değiştirilsin mi?',
+      messageTemperature: '"{{location}}" konumunda zaten bağlı bir sıcaklık sensörü var: {{name}}. Devam etmek onu değiştirecek.',
+      messageHumidity: '"{{location}}" konumunda zaten bağlı bir nem sensörü var: {{name}}. Devam etmek onu değiştirecek.',
+      messageBattery: '"{{location}}" konumunda zaten bağlı bir pil sensörü var: {{name}}. Devam etmek onu değiştirecek.',
+    },
+    autoAdd: {
+      confirmTitle: 'Diğer sensörler de eklensin mi?',
+      confirmMessage: 'Bambuddy bu konum için başka sensörler de buldu. Eklemek istediklerinizi seçin.',
+      added: 'Ayrıca eklendi: {{names}}',
+      noneFound: 'Bu konum için eşleşen sıcaklık, nem veya pil sensörü bulunamadı.',
+      onlyThisOne: 'Sadece bunu',
+    },
+    options: {
+      buttonLabel: 'Sensör seçenekleri',
+      title: 'Konum Sensörü Seçenekleri',
+      description: 'Bu değerler, bir konumun ilk sensörüyle birlikte bir sıcaklık, nem veya pil sensörü otomatik olarak bağlandığında kullanılır.',
+      generalSettings: "Genel ayarlar",
+      pollInterval: "Güncelleme aralığı (saniye)",
+      pollIntervalHint: "Bambuddy'nin Home Assistant'ı ne sıklıkla sorgulayıp ekrandaki sensör değerlerini güncellediği. Minimum 60 saniye.",
+      colorizeValues: 'Sensör değerlerini uyarı eşiklerine göre renklendir',
+      aboveColor: 'Eşik üstü rengi',
+      belowColor: 'Eşik altı rengi',
+      optimalColor: 'Optimal değer rengi',
+      colors: {
+        red: 'Kırmızı',
+        orange: 'Turuncu',
+        yellow: 'Sarı',
+        green: 'Yeşil',
+        blue: 'Mavi',
+        purple: 'Mor',
+        pink: 'Pembe',
+      },
+      saved: 'Seçenekler kaydedildi',
+      saveFailed: 'Seçenekler kaydedilemedi',
+      reset: 'Sıfırla',
+      resetConfirm: {
+        title: 'Mevcut tüm konum sensörleri sıfırlansın mı?',
+        message:
+          'Bu işlem, mevcut tüm konum sıcaklık, nem ve pil sensörlerinin uyarı eşiklerini, bildirimlerini ve kart görünürlüğünü yukarıda yapılandırılan değerlerle üzerine yazar ve her sensörün adını Home Assistant\'taki dost adına geri yükler — yazıcı sensörleri etkilenmez. Bu işlem geri alınamaz. Sensör sayısına bağlı olarak bu işlem biraz zaman alabilir.',
+      },
+      resetDone: '{{count}} konum sensörüne uygulandı',
+      resetFailed: 'Konum sensörleri sıfırlanamadı',
+    },
+    deleteAllConfirm: {
+      title: 'Tüm sensörler silinsin mi?',
+      message: '"{{location}}" konumuna bağlı tüm {{count}} sensör silinecek. Bu işlem geri alınamaz.',
+    },
+    currentlyBound: 'Şu anda bağlı: {{entity}}',
+    error: {
+      locationRequired: 'Bir depolama konumu seçin',
+    },
+    toast: {
+      created: 'Sensör eklendi',
+      updated: 'Sensör kaydedildi',
+      deleted: 'Sensör kaldırıldı',
+    },
+  },
   smartPlugs: {
     offline: 'Çevrimdışı',
     admin: 'Yönetici',
@@ -6048,8 +6123,10 @@ export default {
     notificationEvents: 'Bildirim Olayları',
     progressPercent: '(%25, %50, %75)',
     bedCooledAfterPrint: '(baskı tamamlandıktan sonra)',
-    haSensorAlert: 'Sensör Uyarısı',
+    haSensorAlert: 'Yazıcı Sensörü Uyarısı',
     haSensorAlertDescription: '(bağlı bir Home Assistant sensörü dikkat gerektiriyor)',
+    locationHaSensorAlert: 'Konum Sensörü Uyarısı',
+    locationHaSensorAlertDescription: '(bir depolama konumuna bağlı Home Assistant sensörü dikkat gerektiriyor)',
     eventPriority: {
       sectionTitle: 'ntfy Önceliği',
       helpNtfy: 'Her etkin olay için bir öncelik seçin. ntfy uyarıları kademelendirmek için bunları kullanır (ses, görünürlük, push davranışı). Burada ayarlanmamış seviyeler ntfy sunucu varsayılanını kullanır.',

+ 79 - 2
frontend/src/i18n/locales/uk.ts

@@ -1863,6 +1863,7 @@ export default {
     tabs: {
       general: "Загальні",
       smartPlugs: "Розумні розетки",
+      sensors: "Датчики",
       notifications: "Сповіщення",
       queue: "Робочий процес",
       queueDispatch: "Черга та відправка",
@@ -4534,6 +4535,7 @@ export default {
     edit: "Редагувати місцезнаходження",
     name: "Назва",
     spools: "Котушки",
+    sensors: "Датчики",
     empty: "Місць зберігання ще немає. Створіть свою першу полицю або ящик.",
     manage: "Розташування",
     createPlaceholder: "напр. Полиця А, ящик 1",
@@ -4546,6 +4548,7 @@ export default {
     deleteBlocked: "Видаліть усі котушки з цього місця перед видаленням",
     confirmDelete: "Видалити \"{{name}}\"?",
     confirmDeleteMessage: "Це місце буде видалено з каталогу. Котушки спочатку потрібно перемістити.",
+    confirmDeleteMessageWithSensors: 'Це місце буде видалено з каталогу. Котушки спочатку потрібно перемістити. Підключені датчики також буде видалено.',
   },
 
   // Inventory
@@ -4712,6 +4715,9 @@ export default {
     spoolName: "Котушка",
     costPerKg: "Вартість за кг",
     storageLocation: "Місце зберігання",
+    temperature: "Температура",
+    humidity: "Вологість",
+    battery: "Батарея",
     storageLocationPlaceholder: "напр. Полиця А, ящик 1",
     openInInventory: "Відкрити в інвентарі",
     measuredWeightError: "Виміряна вага має бути між {{min}}g і {{max}}g.",
@@ -5789,7 +5795,7 @@ export default {
       on: "Увімк",
       off: "Вимк",
     },
-    sectionTitle: "Датчики Home Assistant",
+    sectionTitle: "Датчики Home Assistant (принтери)",
     add: "Додати датчик",
     addTitle: "Додати датчик Home Assistant",
     editTitle: "Редагувати датчик Home Assistant",
@@ -5824,6 +5830,75 @@ export default {
       alertRequired: "Спершу задайте умову сповіщення",
     },
   },
+  locationHaSensors: {
+    sectionTitle: "Датчики Home Assistant (місця зберігання)",
+    sectionDescription:
+      "Прив'яжіть датчики температури, вологості або заряду батареї Home Assistant до місць зберігання, щоб показувати актуальні значення на картці філаменту і в таблиці. Для кожного місця зберігання дозволено лише один датчик на категорію.",
+    add: "Додати датчик",
+    addTitle: "Додати датчик Home Assistant",
+    editTitle: "Редагувати датчик Home Assistant",
+    empty: "Датчиків ще немає. Прив'яжіть датчик температури або вологості з Home Assistant, щоб показати його на картці філаменту.",
+    unknownLocation: "Невідоме місце",
+    location: "Місце зберігання",
+    showOnCard: "Показувати на картці філаменту",
+    overwriteConfirm: {
+      title: "Замінити наявний датчик?",
+      messageTemperature: "«{{location}}» вже має прив'язаний датчик температури: {{name}}. Продовження замінить його.",
+      messageHumidity: "«{{location}}» вже має прив'язаний датчик вологості: {{name}}. Продовження замінить його.",
+      messageBattery: "«{{location}}» вже має прив'язаний датчик заряду батареї: {{name}}. Продовження замінить його.",
+    },
+    autoAdd: {
+      confirmTitle: "Додати також інші датчики?",
+      confirmMessage: "Bambuddy також знайшов інші датчики для цього місця. Виберіть, які додати.",
+      added: "Також додано: {{names}}",
+      noneFound: "Відповідних датчиків температури, вологості чи заряду батареї для цього місця не знайдено.",
+      onlyThisOne: "Лише цей",
+    },
+    options: {
+      buttonLabel: "Параметри датчиків",
+      title: "Параметри датчиків місця зберігання",
+      description: "Ці значення використовуються, коли датчик температури, вологості чи заряду батареї автоматично прив'язується разом із першим датчиком місця зберігання.",
+      generalSettings: 'Загальні налаштування',
+      pollInterval: 'Інтервал оновлення (секунди)',
+      pollIntervalHint: 'Як часто Bambuddy опитує Home Assistant і оновлює значення датчиків на екрані. Мінімум 60 секунд.',
+      colorizeValues: 'Розфарбовувати значення датчиків відповідно до порогів тривоги',
+      aboveColor: 'Колір перевищення порогу',
+      belowColor: 'Колір нижче порогу',
+      optimalColor: 'Колір оптимального значення',
+      colors: {
+        red: 'Червоний',
+        orange: 'Помаранчевий',
+        yellow: 'Жовтий',
+        green: 'Зелений',
+        blue: 'Синій',
+        purple: 'Фіолетовий',
+        pink: 'Рожевий',
+      },
+      saved: "Параметри збережено",
+      saveFailed: "Не вдалося зберегти параметри",
+      reset: "Скинути",
+      resetConfirm: {
+        title: "Скинути всі наявні датчики місць зберігання?",
+        message:
+          "Це замінить пороги тривоги, сповіщення та видимість на картці для всіх наявних датчиків температури, вологості та заряду батареї місць зберігання значеннями, вказаними вище, а також відновить ім'я кожного датчика до його зрозумілої назви в Home Assistant — датчики принтерів не зачіпаються. Цю дію не можна скасувати. Залежно від кількості датчиків це може зайняти деякий час.",
+      },
+      resetDone: "Застосовано до {{count}} датчиків місць зберігання",
+      resetFailed: "Не вдалося скинути датчики місць зберігання",
+    },
+    deleteAllConfirm: {
+      title: "Видалити всі датчики?",
+      message: "Це видалить усі {{count}} датчиків, прив'язаних до «{{location}}». Цю дію не можна скасувати.",
+    },
+    currentlyBound: "Наразі прив'язано: {{entity}}",
+    error: {
+      locationRequired: "Оберіть місце зберігання",
+    },
+    toast: {
+      created: "Датчик додано",
+      updated: "Датчик збережено",
+      deleted: "Датчик видалено",
+    },
+  },
   smartPlugs: {
     offline: "Не в мережі",
     admin: "Адміністрування",
@@ -6141,8 +6216,10 @@ export default {
     notificationEvents: "Події сповіщень",
     progressPercent: "(25%, 50%, 75%)",
     bedCooledAfterPrint: "(після завершення друку)",
-    haSensorAlert: "Сповіщення датчика",
+    haSensorAlert: "Сповіщення датчика принтера",
     haSensorAlertDescription: "(пов'язаний датчик Home Assistant потребує уваги)",
+    locationHaSensorAlert: "Сповіщення датчика місця зберігання",
+    locationHaSensorAlertDescription: "(датчик Home Assistant, пов'язаний з місцем зберігання, потребує уваги)",
     // Per-event ntfy priority (#990)
     eventPriority: {
       sectionTitle: "Пріоритет ntfy",

+ 78 - 2
frontend/src/i18n/locales/zh-CN.ts

@@ -1846,6 +1846,7 @@ export default {
     tabs: {
       general: '通用',
       smartPlugs: '智能插座',
+      sensors: '传感器',
       notifications: '通知',
       queue: '工作流',
       queueDispatch: '队列与调度',
@@ -4489,6 +4490,7 @@ export default {
     edit: '编辑位置',
     name: '名称',
     spools: '线轴',
+    sensors: '传感器',
     empty: '尚无存储位置。创建第一个货架或抽屉。',
     manage: '位置',
     createPlaceholder: '例如:A 架、抽屉 1',
@@ -4501,6 +4503,7 @@ export default {
     deleteBlocked: '删除前请移走此位置上的所有线轴',
     confirmDelete: '删除「{{name}}」?',
     confirmDeleteMessage: '此位置将从目录中移除。请先移走线轴。',
+    confirmDeleteMessageWithSensors: '此位置将从目录中移除。请先移走线轴。关联的传感器也将被删除。',
   },
 
   // Inventory
@@ -4667,6 +4670,9 @@ export default {
     spoolName: '线轴',
     costPerKg: '每公斤成本',
     storageLocation: '存放位置',
+    temperature: '温度',
+    humidity: '湿度',
+    battery: '电池',
     storageLocationPlaceholder: '例如:货架A,抽屉1',
     openInInventory: '在库存中查看',
     measuredWeightError: '称量重量必须在 {{min}}g 到 {{max}}g 之间。',
@@ -5735,7 +5741,7 @@ export default {
       on: '开',
       off: '关',
     },
-    sectionTitle: 'Home Assistant 传感器',
+    sectionTitle: 'Home Assistant 传感器(打印机)',
     add: '添加传感器',
     addTitle: '添加 Home Assistant 传感器',
     editTitle: '编辑 Home Assistant 传感器',
@@ -5770,6 +5776,74 @@ export default {
       alertRequired: '请先设置警报条件',
     },
   },
+  locationHaSensors: {
+    sectionTitle: 'Home Assistant 传感器(存放位置)',
+    sectionDescription: '将 Home Assistant 的温度、湿度或电量传感器绑定到存放位置,即可在耗材卡片和表格中显示实时数值。每个存放位置每个类别只能绑定一个传感器。',
+    add: '添加传感器',
+    addTitle: '添加 Home Assistant 传感器',
+    editTitle: '编辑 Home Assistant 传感器',
+    empty: '还没有传感器。绑定 Home Assistant 的温度或湿度传感器,即可显示在耗材卡片上。',
+    unknownLocation: '未知位置',
+    location: '存放位置',
+    showOnCard: '在耗材卡片上显示',
+    overwriteConfirm: {
+      title: '要替换现有传感器吗?',
+      messageTemperature: '"{{location}}" 已绑定温度传感器:{{name}}。继续将替换它。',
+      messageHumidity: '"{{location}}" 已绑定湿度传感器:{{name}}。继续将替换它。',
+      messageBattery: '"{{location}}" 已绑定电池传感器:{{name}}。继续将替换它。',
+    },
+    autoAdd: {
+      confirmTitle: '是否也添加其他传感器?',
+      confirmMessage: 'Bambuddy 还为此位置找到了其他传感器。请选择要一并添加的传感器。',
+      added: '已同时添加:{{names}}',
+      noneFound: '未找到该位置匹配的温度、湿度或电池传感器。',
+      onlyThisOne: '仅此一个',
+    },
+    options: {
+      buttonLabel: '传感器选项',
+      title: '位置传感器选项',
+      description: '当某个位置绑定第一个传感器时,若自动添加温度、湿度或电池传感器,将使用这些默认值。',
+      generalSettings: '常规设置',
+      pollInterval: '更新间隔(秒)',
+      pollIntervalHint: 'Bambuddy 轮询 Home Assistant 并刷新屏幕上传感器数值的频率。最少 60 秒。',
+      colorizeValues: '根据警报阈值为传感器数值着色',
+      aboveColor: '超过阈值的颜色',
+      belowColor: '低于阈值的颜色',
+      optimalColor: '最佳值颜色',
+      colors: {
+        red: '红色',
+        orange: '橙色',
+        yellow: '黄色',
+        green: '绿色',
+        blue: '蓝色',
+        purple: '紫色',
+        pink: '粉色',
+      },
+      saved: '选项已保存',
+      saveFailed: '保存选项失败',
+      reset: '重置',
+      resetConfirm: {
+        title: '要重置所有现有位置传感器吗?',
+        message:
+          '这将使用上方配置的值覆盖所有现有存储位置温度、湿度和电池传感器的警报阈值、通知和卡片可见性设置,并将每个传感器的名称恢复为其在 Home Assistant 中的友好名称——不会影响打印机传感器。此操作无法撤销。根据传感器数量的不同,这可能需要一些时间。',
+      },
+      resetDone: '已应用于 {{count}} 个位置传感器',
+      resetFailed: '重置位置传感器失败',
+    },
+    deleteAllConfirm: {
+      title: '删除所有传感器?',
+      message: '这将删除绑定到"{{location}}"的全部 {{count}} 个传感器。此操作无法撤销。',
+    },
+    currentlyBound: '当前绑定:{{entity}}',
+    error: {
+      locationRequired: '请选择存放位置',
+    },
+    toast: {
+      created: '已添加传感器',
+      updated: '已保存传感器',
+      deleted: '已删除传感器',
+    },
+  },
   smartPlugs: {
     offline: '离线',
     admin: '管理',
@@ -6087,8 +6161,10 @@ export default {
     notificationEvents: '通知事件',
     progressPercent: '(25%、50%、75%)',
     bedCooledAfterPrint: '(打印完成后)',
-    haSensorAlert: '传感器警报',
+    haSensorAlert: '打印机传感器警报',
     haSensorAlertDescription: '(已绑定的 Home Assistant 传感器需要关注)',
+    locationHaSensorAlert: '存放位置传感器警报',
+    locationHaSensorAlertDescription: '(绑定到存放位置的 Home Assistant 传感器需要关注)',
     // Per-event ntfy priority (#990)
     eventPriority: {
       sectionTitle: 'ntfy 优先级',

+ 78 - 2
frontend/src/i18n/locales/zh-TW.ts

@@ -1846,6 +1846,7 @@ export default {
     tabs: {
       general: '通用',
       smartPlugs: '智慧插座',
+      sensors: '感測器',
       notifications: '通知',
       queue: '工作流程',
       queueDispatch: '佇列與分派',
@@ -4489,6 +4490,7 @@ export default {
     edit: '編輯位置',
     name: '名稱',
     spools: '線軸',
+    sensors: '感測器',
     empty: '尚無儲存位置。建立第一個貨架或抽屜。',
     manage: '位置',
     createPlaceholder: '例如:A 架、抽屜 1',
@@ -4501,6 +4503,7 @@ export default {
     deleteBlocked: '刪除前請移走此位置上的所有線軸',
     confirmDelete: '刪除「{{name}}」?',
     confirmDeleteMessage: '此位置將從目錄中移除。請先移走線軸。',
+    confirmDeleteMessageWithSensors: '此位置將從目錄中移除。請先移走線軸。關聯的感測器也將一併刪除。',
   },
 
   // Inventory
@@ -4667,6 +4670,9 @@ export default {
     spoolName: '料盤',
     costPerKg: '每公斤成本',
     storageLocation: '存放位置',
+    temperature: '溫度',
+    humidity: '濕度',
+    battery: '電池',
     storageLocationPlaceholder: '例如:貨架A,抽屜1',
     openInInventory: '在庫存中查看',
     measuredWeightError: '稱量重量必須在 {{min}}g 到 {{max}}g 之間。',
@@ -5735,7 +5741,7 @@ export default {
       on: '開',
       off: '關',
     },
-    sectionTitle: 'Home Assistant 感測器',
+    sectionTitle: 'Home Assistant 感測器(印表機)',
     add: '新增感測器',
     addTitle: '新增 Home Assistant 感測器',
     editTitle: '編輯 Home Assistant 感測器',
@@ -5770,6 +5776,74 @@ export default {
       alertRequired: '請先設定警報條件',
     },
   },
+  locationHaSensors: {
+    sectionTitle: 'Home Assistant 感測器(存放位置)',
+    sectionDescription: '將 Home Assistant 的溫度、濕度或電量感測器綁定到存放位置,即可在線材卡片和表格中顯示即時數值。每個存放位置每個類別只能綁定一個感測器。',
+    add: '新增感測器',
+    addTitle: '新增 Home Assistant 感測器',
+    editTitle: '編輯 Home Assistant 感測器',
+    empty: '還沒有感測器。綁定 Home Assistant 的溫度或濕度感測器,即可顯示在線材卡片上。',
+    unknownLocation: '未知位置',
+    location: '存放位置',
+    showOnCard: '在線材卡片上顯示',
+    overwriteConfirm: {
+      title: '要取代現有感測器嗎?',
+      messageTemperature: '"{{location}}" 已綁定溫度感測器:{{name}}。繼續將取代它。',
+      messageHumidity: '"{{location}}" 已綁定濕度感測器:{{name}}。繼續將取代它。',
+      messageBattery: '"{{location}}" 已綁定電池感測器:{{name}}。繼續將取代它。',
+    },
+    autoAdd: {
+      confirmTitle: '是否也新增其他感測器?',
+      confirmMessage: 'Bambuddy 也為此位置找到了其他感測器。請選擇要一併新增的感測器。',
+      added: '已同時新增:{{names}}',
+      noneFound: '未找到該位置相符的溫度、濕度或電池感測器。',
+      onlyThisOne: '僅此一個',
+    },
+    options: {
+      buttonLabel: '感測器選項',
+      title: '位置感測器選項',
+      description: '當某個位置綁定第一個感測器時,若自動新增溫度、濕度或電池感測器,將使用這些預設值。',
+      generalSettings: '一般設定',
+      pollInterval: '更新間隔(秒)',
+      pollIntervalHint: 'Bambuddy 輪詢 Home Assistant 並刷新畫面上感測器數值的頻率。最少 60 秒。',
+      colorizeValues: '依警示閾值為感測器數值著色',
+      aboveColor: '超過閾值的顏色',
+      belowColor: '低於閾值的顏色',
+      optimalColor: '最佳值顏色',
+      colors: {
+        red: '紅色',
+        orange: '橙色',
+        yellow: '黃色',
+        green: '綠色',
+        blue: '藍色',
+        purple: '紫色',
+        pink: '粉色',
+      },
+      saved: '選項已儲存',
+      saveFailed: '儲存選項失敗',
+      reset: '重設',
+      resetConfirm: {
+        title: '要重設所有現有位置感測器嗎?',
+        message:
+          '這將使用上方設定的值覆寫所有現有儲存位置溫度、濕度及電池感測器的警示閾值、通知與卡片顯示設定,並將每個感測器的名稱還原為其在 Home Assistant 中的易記名稱——不會影響印表機感測器。此操作無法復原。依感測器數量不同,這可能需要一些時間。',
+      },
+      resetDone: '已套用至 {{count}} 個位置感測器',
+      resetFailed: '重設位置感測器失敗',
+    },
+    deleteAllConfirm: {
+      title: '刪除所有感測器?',
+      message: '這將刪除綁定到"{{location}}"的全部 {{count}} 個感測器。此操作無法復原。',
+    },
+    currentlyBound: '目前綁定:{{entity}}',
+    error: {
+      locationRequired: '請選擇存放位置',
+    },
+    toast: {
+      created: '已新增感測器',
+      updated: '已儲存感測器',
+      deleted: '已移除感測器',
+    },
+  },
   smartPlugs: {
     offline: '離線',
     admin: '管理',
@@ -6087,8 +6161,10 @@ export default {
     notificationEvents: '通知事件',
     progressPercent: '(25%、50%、75%)',
     bedCooledAfterPrint: '(列印完成後)',
-    haSensorAlert: '感測器警報',
+    haSensorAlert: '印表機感測器警報',
     haSensorAlertDescription: '(已綁定的 Home Assistant 感測器需要注意)',
+    locationHaSensorAlert: '存放位置感測器警報',
+    locationHaSensorAlertDescription: '(綁定至存放位置的 Home Assistant 感測器需要注意)',
     // Per-event ntfy priority (#990)
     eventPriority: {
       sectionTitle: 'ntfy 優先級',

+ 1 - 0
frontend/src/lib/settingsSearch.ts

@@ -11,6 +11,7 @@
 export type SettingsSearchTab =
   | 'general'
   | 'plugs'
+  | 'sensors'
   | 'notifications'
   | 'queue'
   | 'filament'

+ 337 - 15
frontend/src/pages/InventoryPage.tsx

@@ -1,6 +1,6 @@
 import { useState, useMemo, useEffect, useRef, useCallback, type ReactNode } from 'react';
 import { useSearchParams } from 'react-router-dom';
-import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import {
   Plus, Loader2, Trash2, Archive, RotateCcw, Edit2, Package,
@@ -11,9 +11,10 @@ import {
 } from 'lucide-react';
 import { ForecastPanel } from '../components/ForecastPanel';
 import { api, spoolbuddyApi, ApiError } from '../api/client';
-import type { InventorySpool, SpoolCatalogEntry } from '../api/client';
+import type { InventorySpool, SpoolCatalogEntry, LocationHASensorReading } from '../api/client';
 import { Button } from '../components/Button';
 import { FilamentSwatch } from '../components/FilamentSwatch';
+import { describeHASensorReading, iconForHASensor } from '../utils/haSensorDisplay';
 import { buildFilamentBackground } from '../components/filamentSwatchHelpers';
 import {SpoolFormModal, type SpoolFormMode} from '../components/SpoolFormModal';
 import { ConfirmModal } from '../components/ConfirmModal';
@@ -34,6 +35,12 @@ import {
   invalidateSpoolAndLocationQueries,
 } from '../utils/inventoryQueries';
 import { aggregateGroupSpool } from '../utils/inventoryGrouping';
+import {
+  locationSensorReadingAlertStatus,
+  locationSensorValueColorClass,
+  useLocationSensorColorPrefs,
+  type LocationSensorAlertColor,
+} from '../utils/locationSensorDefaults';
 
 type ArchiveFilter = 'active' | 'archived';
 type UsageFilter = 'all' | 'used' | 'new' | 'lowstock';
@@ -79,6 +86,9 @@ const DEFAULT_COLUMNS: ColumnConfig[] = [
   { id: 'slicer_filament', label: 'Slicer Filament', visible: false },
   { id: 'location', label: 'Location', visible: true },
   { id: 'storage_location', label: 'Storage Location', visible: false },
+  { id: 'temperature', label: 'Temperature', visible: false },
+  { id: 'humidity', label: 'Humidity', visible: false },
+  { id: 'battery', label: 'Battery', visible: false },
   { id: 'label_weight', label: 'Label', visible: true },
   { id: 'net', label: 'Net', visible: true },
   { id: 'gross', label: 'Gross', visible: false },
@@ -107,9 +117,21 @@ function loadColumnConfig(): ColumnConfig[] {
       const storedIds = new Set(parsed.map((c) => c.id));
       // Keep stored columns that still exist in defaults
       const validStored = parsed.filter((c) => defaultIds.has(c.id));
-      // Add any new default columns not in stored config
-      const newColumns = DEFAULT_COLUMNS.filter((c) => !storedIds.has(c.id));
-      return [...validStored, ...newColumns];
+      const merged = [...validStored];
+      for (const col of DEFAULT_COLUMNS) {
+        if (storedIds.has(col.id)) continue;
+        const defaultIndex = DEFAULT_COLUMNS.indexOf(col);
+        let insertAt = merged.length;
+        for (let i = defaultIndex - 1; i >= 0; i--) {
+          const idx = merged.findIndex((c) => c.id === DEFAULT_COLUMNS[i].id);
+          if (idx !== -1) {
+            insertAt = idx + 1;
+            break;
+          }
+        }
+        merged.splice(insertAt, 0, col);
+      }
+      return merged;
     }
   } catch {
     // Ignore errors
@@ -168,10 +190,15 @@ type CellCtx = {
   pct: number;
   assignmentMap: Record<number, LocationDisplay>;
   catalogMap: Record<number, SpoolCatalogEntry>;
+  locationReadingsMap: Record<number, LocationHASensorReading[]>;
   currencySymbol: string;
   dateFormat: DateFormat;
   t: TFn;
   onSyncWeight?: (spool: InventorySpool) => void;
+  colorizeLocationSensors: boolean;
+  locationSensorAboveColor: LocationSensorAlertColor;
+  locationSensorBelowColor: LocationSensorAlertColor;
+  locationSensorOptimalColor: LocationSensorAlertColor;
 };
 
 // Column header labels (25 columns — matching SpoolBuddy exactly)
@@ -188,6 +215,9 @@ const columnHeaders: Record<string, (t: TFn) => string> = {
   slicer_filament: (t) => t('inventory.slicerFilament'),
   location: () => 'Location',
   storage_location: (t) => t('inventory.storageLocation'),
+  temperature: (t) => t('inventory.temperature'),
+  humidity: (t) => t('inventory.humidity'),
+  battery: (t) => t('inventory.battery'),
   label_weight: (t) => t('inventory.labelWeight'),
   net: (t) => t('inventory.net'),
   gross: () => 'Gross',
@@ -270,6 +300,48 @@ const columnCells: Record<string, (ctx: CellCtx) => ReactNode> = {
       </span>
     );
   },
+  temperature: ({ spool, locationReadingsMap, t, colorizeLocationSensors, locationSensorAboveColor, locationSensorBelowColor, locationSensorOptimalColor }) => {
+    const reading = spool.location_id
+      ? locationReadingsMap[spool.location_id]?.find((r) => r.device_class === 'temperature')
+      : undefined;
+    if (!reading) return <span className="text-sm text-bambu-gray/50">-</span>;
+    return (
+      <span
+        title={reading.name}
+        className={`text-sm ${locationSensorCellColor(reading, colorizeLocationSensors, locationSensorAboveColor, locationSensorBelowColor, locationSensorOptimalColor)}`}
+      >
+        {describeLocationSensor(reading, t)}
+      </span>
+    );
+  },
+  humidity: ({ spool, locationReadingsMap, t, colorizeLocationSensors, locationSensorAboveColor, locationSensorBelowColor, locationSensorOptimalColor }) => {
+    const reading = spool.location_id
+      ? locationReadingsMap[spool.location_id]?.find((r) => r.device_class === 'humidity')
+      : undefined;
+    if (!reading) return <span className="text-sm text-bambu-gray/50">-</span>;
+    return (
+      <span
+        title={reading.name}
+        className={`text-sm ${locationSensorCellColor(reading, colorizeLocationSensors, locationSensorAboveColor, locationSensorBelowColor, locationSensorOptimalColor)}`}
+      >
+        {describeLocationSensor(reading, t)}
+      </span>
+    );
+  },
+  battery: ({ spool, locationReadingsMap, t, colorizeLocationSensors, locationSensorAboveColor, locationSensorBelowColor, locationSensorOptimalColor }) => {
+    const reading = spool.location_id
+      ? locationReadingsMap[spool.location_id]?.find((r) => r.device_class === 'battery')
+      : undefined;
+    if (!reading) return <span className="text-sm text-bambu-gray/50">-</span>;
+    return (
+      <span
+        title={reading.name}
+        className={`text-sm ${locationSensorCellColor(reading, colorizeLocationSensors, locationSensorAboveColor, locationSensorBelowColor, locationSensorOptimalColor)}`}
+      >
+        {describeLocationSensor(reading, t)}
+      </span>
+    );
+  },
   label_weight: ({ spool }) => (
     <span className="text-sm text-white">{formatWeight(spool.label_weight)}</span>
   ),
@@ -404,7 +476,14 @@ const columnCells: Record<string, (ctx: CellCtx) => ReactNode> = {
 };
 
 // Sort value extractors — return a comparable value for each sortable column
-const columnSortValues: Record<string, (spool: InventorySpool, assignmentMap: Record<number, LocationDisplay>) => string | number> = {
+const columnSortValues: Record<
+  string,
+  (
+    spool: InventorySpool,
+    assignmentMap: Record<number, LocationDisplay>,
+    locationReadingsMap: Record<number, LocationHASensorReading[]>
+  ) => string | number
+> = {
   id: (s) => s.id,
   added_time: (s) => s.created_at || '',
   encode_time: (s) => s.encode_time || '',
@@ -443,6 +522,18 @@ const columnSortValues: Record<string, (spool: InventorySpool, assignmentMap: Re
     const expectedGross = Math.max(0, s.label_weight - s.weight_used) + s.core_weight;
     return Math.abs(s.last_scale_weight - expectedGross);
   },
+  temperature: (s, _am, lrm) => {
+    const readings = s.location_id ? lrm[s.location_id] : undefined;
+    return readings?.find((r) => r.device_class === 'temperature')?.value ?? -Infinity;
+  },
+  humidity: (s, _am, lrm) => {
+    const readings = s.location_id ? lrm[s.location_id] : undefined;
+    return readings?.find((r) => r.device_class === 'humidity')?.value ?? -Infinity;
+  },
+  battery: (s, _am, lrm) => {
+    const readings = s.location_id ? lrm[s.location_id] : undefined;
+    return readings?.find((r) => r.device_class === 'battery')?.value ?? -Infinity;
+  },
 };
 
 const SORT_STATE_KEY = 'bambuddy-inventory-sort';
@@ -562,6 +653,14 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
   });
 
   const dateFormat: DateFormat = settings?.date_format || 'system';
+  const locationSensorPollIntervalMs = (settings?.location_sensor_poll_interval || 120) * 1000;
+
+  const {
+    colorize: colorizeLocationSensors,
+    aboveColor: locationSensorAboveColor,
+    belowColor: locationSensorBelowColor,
+    optimalColor: locationSensorOptimalColor,
+  } = useLocationSensorColorPrefs();
 
   // Query key and fetch function differ based on data source
   const spoolsQueryKey = spoolmanMode ? ['spoolman-inventory-spools'] : ['inventory-spools'];
@@ -1056,6 +1155,58 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
     return map;
   }, [catalogEntries]);
 
+  // Not polled — it only changes via explicit create/edit/delete, all of
+  // which already invalidate this key, and it also seeds the SpoolCard
+  // footers below (same query key, so they share this fetch instead of each
+  // issuing their own).
+  const { data: locationHaSensorsList } = useQuery({
+    queryKey: ['locationHaSensors'],
+    queryFn: () => api.getLocationHASensors(),
+  });
+
+  const locationIdsWithSensors = useMemo(
+    () => new Set((locationHaSensorsList ?? []).map((s) => s.location_id)),
+    [locationHaSensorsList]
+  );
+
+  const usedLocationIds = useMemo(() => {
+    const ids = new Set<number>();
+    for (const s of spools || []) {
+      // Skip locations with no bound sensor at all — polling them would only
+      // ever come back empty, and most installs have far more storage
+      // locations than ones actually wired up to Home Assistant.
+      if (s.location_id && locationIdsWithSensors.has(s.location_id)) ids.add(s.location_id);
+    }
+    return Array.from(ids);
+  }, [spools, locationIdsWithSensors]);
+
+  // Card view always needs readings (the SpoolCard footer below reads this
+  // same cache and filters to show_on_card itself); table view only needs
+  // them when a sensor column is actually visible, since the default
+  // column config hides all three.
+  const needsLocationReadings =
+    viewMode === 'cards' ||
+    (viewMode === 'table' &&
+      columnConfig.some((c) => c.visible && (c.id === 'temperature' || c.id === 'humidity' || c.id === 'battery')));
+
+  const locationReadingsQueries = useQueries({
+    queries: usedLocationIds.map((locationId) => ({
+      queryKey: ['locationHaSensorReadings', locationId],
+      queryFn: () => api.getLocationHASensorReadings(locationId, false),
+      refetchInterval: locationSensorPollIntervalMs,
+      enabled: needsLocationReadings,
+    })),
+  });
+
+  const locationReadingsMap = useMemo(() => {
+    const map: Record<number, LocationHASensorReading[]> = {};
+    usedLocationIds.forEach((locationId, i) => {
+      const data = locationReadingsQueries[i]?.data;
+      if (data) map[locationId] = data;
+    });
+    return map;
+  }, [usedLocationIds, locationReadingsQueries]);
+
   // Top materials by weight for stat card pills
   const topMaterials = useMemo(() => {
     if (!stats) return [];
@@ -1209,14 +1360,14 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
     const extractor = columnSortValues[sortState.column];
     if (!extractor) return filteredSpools;
     const sorted = [...filteredSpools].sort((a, b) => {
-      const va = extractor(a, assignmentMap);
-      const vb = extractor(b, assignmentMap);
+      const va = extractor(a, assignmentMap, locationReadingsMap);
+      const vb = extractor(b, assignmentMap, locationReadingsMap);
       if (va < vb) return sortState.direction === 'asc' ? -1 : 1;
       if (va > vb) return sortState.direction === 'asc' ? 1 : -1;
       return 0;
     });
     return sorted;
-  }, [filteredSpools, sortState, assignmentMap]);
+  }, [filteredSpools, sortState, assignmentMap, locationReadingsMap]);
 
   // Group similar spools when toggle is active
   const displayItems = useMemo((): DisplayItem[] => {
@@ -1942,6 +2093,10 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
                                 onPrintLabel={() => setLabelPickerSpoolIds([spool.id])}
                                 onCopy={() => setFormModal({ spool: spool, mode: 'copy' })}
                                 t={t}
+                                colorizeLocationSensors={colorizeLocationSensors}
+                                locationSensorAboveColor={locationSensorAboveColor}
+                                locationSensorBelowColor={locationSensorBelowColor}
+                                locationSensorOptimalColor={locationSensorOptimalColor}
                               />
                             );
                           })}
@@ -1963,6 +2118,10 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
                     onPrintLabel={() => setLabelPickerSpoolIds([spool.id])}
                     onCopy={() => setFormModal({ spool: spool, mode: 'copy' })}
                     t={t}
+                    colorizeLocationSensors={colorizeLocationSensors}
+                    locationSensorAboveColor={locationSensorAboveColor}
+                    locationSensorBelowColor={locationSensorBelowColor}
+                    locationSensorOptimalColor={locationSensorOptimalColor}
                   />
                 );
               })}
@@ -2088,10 +2247,15 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
                           visibleColumns={visibleColumns}
                           assignmentMap={assignmentMap}
                           catalogMap={catalogMap}
+                          locationReadingsMap={locationReadingsMap}
                           currencySymbol={currencySymbol}
                           dateFormat={dateFormat}
                           t={t}
                           onSyncWeight={handleSyncWeight}
+                          colorizeLocationSensors={colorizeLocationSensors}
+                          locationSensorAboveColor={locationSensorAboveColor}
+                          locationSensorBelowColor={locationSensorBelowColor}
+                          locationSensorOptimalColor={locationSensorOptimalColor}
                         />
                       );
                     }
@@ -2116,10 +2280,15 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
                         visibleColumns={visibleColumns}
                         assignmentMap={assignmentMap}
                         catalogMap={catalogMap}
+                        locationReadingsMap={locationReadingsMap}
                         currencySymbol={currencySymbol}
                         dateFormat={dateFormat}
                         t={t}
                         onSyncWeight={handleSyncWeight}
+                        colorizeLocationSensors={colorizeLocationSensors}
+                        locationSensorAboveColor={locationSensorAboveColor}
+                        locationSensorBelowColor={locationSensorBelowColor}
+                        locationSensorOptimalColor={locationSensorOptimalColor}
                       />
                     );
                   })}
@@ -2420,6 +2589,7 @@ function PaginationBar({
 /* Spool card for cards view */
 function SpoolCard({
   spool, remaining, pct, onClick, onPrintLabel, onCopy, t,
+  colorizeLocationSensors, locationSensorAboveColor, locationSensorBelowColor, locationSensorOptimalColor,
 }: {
   spool: InventorySpool;
   remaining: number;
@@ -2428,6 +2598,10 @@ function SpoolCard({
   onPrintLabel?: () => void;
   onCopy?: () => void;
   t: (key: string, opts?: Record<string, unknown>) => string;
+  colorizeLocationSensors: boolean;
+  locationSensorAboveColor: LocationSensorAlertColor;
+  locationSensorBelowColor: LocationSensorAlertColor;
+  locationSensorOptimalColor: LocationSensorAlertColor;
 }) {
   const bannerStyle = buildFilamentBackground({
     rgba: spool.rgba,
@@ -2509,9 +2683,20 @@ function SpoolCard({
             </span>
           </div>
         </div>
+        {spool.location_id && (
+          <SpoolLocationFooter
+            locationId={spool.location_id}
+            locationName={spool.storage_location ?? null}
+            isLast={!spool.note}
+            colorize={colorizeLocationSensors}
+            aboveColor={locationSensorAboveColor}
+            belowColor={locationSensorBelowColor}
+            optimalColor={locationSensorOptimalColor}
+          />
+        )}
         {spool.note && (
           <div
-            className="text-xs text-bambu-gray/60 pt-2 border-t border-bambu-dark-tertiary truncate"
+            className="text-xs text-bambu-gray/60 pt-3 border-t border-bambu-dark-tertiary truncate"
             title={spool.note}
           >
             {spool.note}
@@ -2522,11 +2707,132 @@ function SpoolCard({
   );
 }
 
+const LOCATION_SENSOR_CATEGORY_ORDER: Record<string, number> = {
+  temperature: 0,
+  humidity: 1,
+  battery: 2,
+};
+
+function locationSensorIconGapClass(deviceClass: string | null): string {
+  if (deviceClass === 'humidity') return 'mr-[2px]';
+  if (deviceClass === 'battery') return 'mr-[3px]';
+  return '';
+}
+
+// Two decimal places, unlike the printer row's raw value: keeps
+// temperature/humidity/battery cells at a consistent width in the table and
+// card grid (see describeHASensorReading's doc comment).
+function describeLocationSensor(
+  reading: LocationHASensorReading,
+  t: (key: string, opts?: Record<string, unknown>) => string
+): string {
+  return describeHASensorReading(reading, t, { decimals: 2 });
+}
+
+function locationSensorCellColor(
+  reading: LocationHASensorReading,
+  colorize: boolean,
+  aboveColor: LocationSensorAlertColor,
+  belowColor: LocationSensorAlertColor,
+  optimalColor: LocationSensorAlertColor
+): string {
+  if (!colorize) return 'text-bambu-gray';
+  const status = locationSensorReadingAlertStatus(reading);
+  return locationSensorValueColorClass(status, aboveColor, belowColor, optimalColor) || 'text-bambu-gray';
+}
+
+function SpoolLocationFooter({
+  locationId, locationName, isLast, colorize, aboveColor, belowColor, optimalColor,
+}: {
+  locationId: number;
+  locationName: string | null;
+  isLast: boolean;
+  colorize: boolean;
+  aboveColor: LocationSensorAlertColor;
+  belowColor: LocationSensorAlertColor;
+  optimalColor: LocationSensorAlertColor;
+}) {
+  const { t } = useTranslation();
+
+  const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: api.getSettings });
+  const pollIntervalMs = (settings?.location_sensor_poll_interval || 120) * 1000;
+
+  // Same query key as the list fetch in InventoryPage's body, so this is a
+  // cache read (no extra request) whenever that has already run — which it
+  // has, since card view and this footer only render after spools/locations
+  // are loaded.
+  const { data: sensorsList } = useQuery({
+    queryKey: ['locationHaSensors'],
+    queryFn: () => api.getLocationHASensors(),
+  });
+  const hasSensor = (sensorsList ?? []).some((s) => s.location_id === locationId);
+
+  // Same query key as the table-view columns' fetch in InventoryPage's body
+  // (unfiltered, show_on_card=false) — this is a cache read whenever that
+  // has already run, and the two views never need two different requests
+  // for one location. Filtering to card-visible sensors happens here
+  // instead of on the server.
+  const { data: allReadings } = useQuery({
+    queryKey: ['locationHaSensorReadings', locationId],
+    queryFn: () => api.getLocationHASensorReadings(locationId, false),
+    refetchInterval: pollIntervalMs,
+    enabled: hasSensor,
+  });
+  const readings = allReadings?.filter((r) => r.show_on_card);
+
+  if (!readings?.length) return null;
+
+  const batteryReading = readings.find((r) => r.device_class === 'battery');
+  const otherReadings = readings
+    .filter((r) => r.device_class !== 'battery')
+    .sort(
+      (a, b) =>
+        (LOCATION_SENSOR_CATEGORY_ORDER[a.device_class ?? ''] ?? 99) -
+        (LOCATION_SENSOR_CATEGORY_ORDER[b.device_class ?? ''] ?? 99)
+    );
+
+  return (
+    <div
+      className={`flex items-center gap-2 pt-3 border-t border-bambu-dark-tertiary text-xs text-bambu-gray ${isLast ? '-mb-1' : ''}`}
+    >
+      {locationName && <span className="truncate">{locationName}</span>}
+      <span className="text-bambu-gray/40">|</span>
+      <div className="flex items-center gap-3">
+        {otherReadings.map((reading, index) => {
+          const Icon = iconForHASensor(reading);
+          const firstIconOffsetClass = index === 0 ? 'ml-[-3.6px]' : '';
+          return (
+            <span key={reading.id} title={reading.name} className="flex items-center gap-[3px]">
+              <Icon className={`w-3 h-3 ${locationSensorIconGapClass(reading.device_class)} ${firstIconOffsetClass}`} />
+              <span className={locationSensorCellColor(reading, colorize, aboveColor, belowColor, optimalColor)}>
+                {describeLocationSensor(reading, t)}
+              </span>
+            </span>
+          );
+        })}
+      </div>
+      {batteryReading &&
+        (() => {
+          const Icon = iconForHASensor(batteryReading);
+          return (
+            <span key={batteryReading.id} title={batteryReading.name} className="flex items-center gap-[3px] ml-auto">
+              <Icon className={`w-3 h-3 ${locationSensorIconGapClass(batteryReading.device_class)}`} />
+              <span className={locationSensorCellColor(batteryReading, colorize, aboveColor, belowColor, optimalColor)}>
+                {describeLocationSensor(batteryReading, t)}
+              </span>
+            </span>
+          );
+        })()}
+    </div>
+  );
+}
+
 /* Single spool row for table view */
 function SpoolTableRow({
   spool, remaining, pct, isSelected, onToggleSelected,
   onEdit, onCopy, onRestore, onArchive, onDelete, onPrintLabel, onResetConsumedCounter,
-  visibleColumns, assignmentMap, catalogMap, currencySymbol, dateFormat, t, onSyncWeight,
+  visibleColumns, assignmentMap, catalogMap, locationReadingsMap, currencySymbol, dateFormat, t, onSyncWeight,
+  colorizeLocationSensors, locationSensorAboveColor, locationSensorBelowColor, locationSensorOptimalColor,
 }: {
   spool: InventorySpool;
   remaining: number;
@@ -2543,10 +2849,15 @@ function SpoolTableRow({
   visibleColumns: string[];
   assignmentMap: Record<number, LocationDisplay>;
   catalogMap: Record<number, SpoolCatalogEntry>;
+  locationReadingsMap: Record<number, LocationHASensorReading[]>;
   currencySymbol: string;
   dateFormat: DateFormat;
   t: TFn;
   onSyncWeight?: (spool: InventorySpool) => void;
+  colorizeLocationSensors: boolean;
+  locationSensorAboveColor: LocationSensorAlertColor;
+  locationSensorBelowColor: LocationSensorAlertColor;
+  locationSensorOptimalColor: LocationSensorAlertColor;
 }) {
   return (
     <tr
@@ -2568,7 +2879,7 @@ function SpoolTableRow({
       </td>
       {visibleColumns.map((colId) => (
         <td key={colId} className="py-3 px-4">
-          {columnCells[colId]?.({ spool, remaining, pct, assignmentMap, catalogMap, currencySymbol, dateFormat, t, onSyncWeight })}
+          {columnCells[colId]?.({ spool, remaining, pct, assignmentMap, catalogMap, locationReadingsMap, currencySymbol, dateFormat, t, onSyncWeight, colorizeLocationSensors, locationSensorAboveColor, locationSensorBelowColor, locationSensorOptimalColor })}
         </td>
       ))}
       <td className="py-3 px-4">
@@ -2617,7 +2928,8 @@ function SpoolTableRow({
 function SpoolTableGroup({
   spools, headerSpool, remaining, pct, isExpanded, onToggle,
   onEdit, onCopy, onArchive, onDelete, onPrintLabel, onResetConsumedCounter,
-  visibleColumns, assignmentMap, catalogMap, currencySymbol, dateFormat, t, onSyncWeight,
+  visibleColumns, assignmentMap, catalogMap, locationReadingsMap, currencySymbol, dateFormat, t, onSyncWeight,
+  colorizeLocationSensors, locationSensorAboveColor, locationSensorBelowColor, locationSensorOptimalColor,
   selectedIds, onToggleSelected, onToggleGroupSelected,
 }: {
   spools: InventorySpool[];
@@ -2637,10 +2949,15 @@ function SpoolTableGroup({
   visibleColumns: string[];
   assignmentMap: Record<number, LocationDisplay>;
   catalogMap: Record<number, SpoolCatalogEntry>;
+  locationReadingsMap: Record<number, LocationHASensorReading[]>;
   currencySymbol: string;
   dateFormat: DateFormat;
   t: TFn;
   onSyncWeight?: (spool: InventorySpool) => void;
+  colorizeLocationSensors: boolean;
+  locationSensorAboveColor: LocationSensorAlertColor;
+  locationSensorBelowColor: LocationSensorAlertColor;
+  locationSensorOptimalColor: LocationSensorAlertColor;
   selectedIds?: Set<number>;
   onToggleSelected?: (id: number) => void;
   onToggleGroupSelected?: (ids: number[], select: boolean) => void;
@@ -2669,14 +2986,14 @@ function SpoolTableGroup({
             {idx === 0 ? (
               <div className="flex items-center gap-2">
                 <ChevronDown className={`w-4 h-4 text-bambu-gray transition-transform ${isExpanded ? '' : '-rotate-90'}`} />
-                {columnCells[colId]?.({ spool: headerSpool, remaining, pct, assignmentMap, catalogMap, currencySymbol, dateFormat, t, onSyncWeight })}
+                {columnCells[colId]?.({ spool: headerSpool, remaining, pct, assignmentMap, catalogMap, locationReadingsMap, currencySymbol, dateFormat, t, onSyncWeight, colorizeLocationSensors, locationSensorAboveColor, locationSensorBelowColor, locationSensorOptimalColor })}
               </div>
             ) : colId === 'id' ? (
               <span className="text-xs font-medium bg-bambu-green/20 text-bambu-green px-2 py-0.5 rounded-full">
                 {t('inventory.groupedSpools', { count: spools.length })}
               </span>
             ) : (
-              columnCells[colId]?.({ spool: headerSpool, remaining, pct, assignmentMap, catalogMap, currencySymbol, dateFormat, t, onSyncWeight })
+              columnCells[colId]?.({ spool: headerSpool, remaining, pct, assignmentMap, catalogMap, locationReadingsMap, currencySymbol, dateFormat, t, onSyncWeight, colorizeLocationSensors, locationSensorAboveColor, locationSensorBelowColor, locationSensorOptimalColor })
             )}
           </td>
         ))}
@@ -2708,10 +3025,15 @@ function SpoolTableGroup({
             visibleColumns={visibleColumns}
             assignmentMap={assignmentMap}
             catalogMap={catalogMap}
+            locationReadingsMap={locationReadingsMap}
             currencySymbol={currencySymbol}
             dateFormat={dateFormat}
             t={t}
             onSyncWeight={onSyncWeight}
+            colorizeLocationSensors={colorizeLocationSensors}
+            locationSensorAboveColor={locationSensorAboveColor}
+            locationSensorBelowColor={locationSensorBelowColor}
+            locationSensorOptimalColor={locationSensorOptimalColor}
           />
         );
       })}

+ 331 - 10
frontend/src/pages/SettingsPage.tsx

@@ -1,5 +1,5 @@
-import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock, ChevronDown, Save, Mail, Flame, Layers, ListOrdered, Code, Search, Scale, Settings as SettingsIcon, ScanEye, Cog, QrCode, Heart, Briefcase, Workflow, UploadCloud, MonitorPlay } from 'lucide-react';
+import { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query';
+import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Pencil, Send, CheckCircle, XCircle, History, Trash2, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock, ChevronDown, Save, Mail, Flame, Layers, ListOrdered, Code, Search, Scale, Settings as SettingsIcon, ScanEye, Cog, QrCode, Heart, Briefcase, Workflow, UploadCloud, MonitorPlay } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useNavigate, useSearchParams } from 'react-router-dom';
 import { api } from '../api/client';
@@ -10,8 +10,15 @@ import { checkPasswordComplexity } from '../utils/password';
 import { fleetAudience, sponsorHref } from '../utils/fleetAudience';
 import { PRESET_CATEGORIES, parsePresetTriple } from '../utils/temperatureFanPresets';
 import { CALIBRATION_MODES, CALIBRATION_MODE_ACTIVE, CALIBRATION_MODE_INACTIVE } from '../utils/calibrationMode';
+import { inventoryLocationsQueryKey } from '../utils/inventoryQueries';
+import {
+  locationSensorReadingAlertStatus,
+  locationSensorValueColorClass,
+  useLocationSensorColorPrefs,
+} from '../utils/locationSensorDefaults';
+import { describeHASensorReading, iconForHASensor } from '../utils/haSensorDisplay';
 import { PreheatFilamentTargetsEditor } from '../components/PreheatFilamentTargetsEditor';
-import type { APIKey, AppSettings, AppSettingsUpdate, PrinterHASensor, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus, GitHubBackupStatus, CloudAuthStatus, UserCreate, UserUpdate, UserResponse, StorageUsageResponse, CalibrationMode } from '../api/client';
+import type { APIKey, AppSettings, AppSettingsUpdate, PrinterHASensor, LocationHASensor, LocationHASensorReading, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus, GitHubBackupStatus, CloudAuthStatus, UserCreate, UserUpdate, UserResponse, StorageUsageResponse, CalibrationMode } from '../api/client';
 import { Card, CardContent, CardDensityProvider, CardHeader } from '../components/Card';
 import { SlicerPipelinesPanel } from '../components/SlicerPipelinesPanel';
 import { CameraTokensSection } from './CameraTokensPage';
@@ -22,6 +29,8 @@ import { Button } from '../components/Button';
 import { SmartPlugCard } from '../components/SmartPlugCard';
 import { AddSmartPlugModal } from '../components/AddSmartPlugModal';
 import { HASensorModal } from '../components/HASensorModal';
+import { LocationHASensorModal } from '../components/LocationHASensorModal';
+import { LocationSensorOptionsModal } from '../components/LocationSensorOptionsModal';
 import { NotificationProviderCard } from '../components/NotificationProviderCard';
 import { AddNotificationModal } from '../components/AddNotificationModal';
 import { NotificationTemplateEditor } from '../components/NotificationTemplateEditor';
@@ -49,13 +58,13 @@ import { defaultNavItems, getDefaultView, setDefaultView } from '../components/L
 import { availableLanguages } from '../i18n';
 import { useToast } from '../contexts/ToastContext';
 import { useTheme, type ThemeStyle, type DarkBackground, type LightBackground, type ThemeAccent } from '../contexts/ThemeContext';
-import { useState, useEffect, useRef, useCallback } from 'react';
+import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
 import { Gauge, Palette } from 'lucide-react';
 import { registerSettingsSearch, getSettingsSearchEntries } from '../lib/settingsSearch';
 import type { UsersSubTab } from '../lib/settingsSearch';
 import { availableEngines, hasEngineChoice, resolveEngine, type SliceEngineId } from '../lib/sliceEngines';
 
-const validTabs = ['general', 'plugs', 'notifications', 'queue', 'filament', 'network', 'apikeys', 'virtual-printer', 'spoolbuddy', 'failure-detection', 'users', 'backup'] as const;
+const validTabs = ['general', 'plugs', 'sensors', 'notifications', 'queue', 'filament', 'network', 'apikeys', 'virtual-printer', 'spoolbuddy', 'failure-detection', 'users', 'backup'] as const;
 type TabType = typeof validTabs[number];
 
 // Cross-tab search registrations for cards rendered inline in this file.
@@ -70,6 +79,8 @@ registerSettingsSearch({ labelKey: 'settings.fileManager', tab: 'general', keywo
 registerSettingsSearch({ labelKey: 'settings.updates', tab: 'general', keywords: 'updates version firmware beta check', anchor: 'card-updates' });
 registerSettingsSearch({ labelKey: 'settings.dataManagement', tab: 'general', keywords: 'data reset clear logs notifications preferences', anchor: 'card-data' });
 registerSettingsSearch({ labelKey: 'settings.smartPlugs', tab: 'plugs', keywords: 'smart plug energy power automation tapo kasa tplink shelly', anchor: 'card-plugs' });
+registerSettingsSearch({ labelKey: 'haSensors.sectionTitle', tab: 'sensors', keywords: 'home assistant sensor printer temperature humidity alert notify block print', anchor: 'card-ha-sensors' });
+registerSettingsSearch({ labelKey: 'locationHaSensors.sectionTitle', tab: 'sensors', keywords: 'home assistant sensor location storage box temperature humidity battery alert notify', anchor: 'card-location-sensors' });
 registerSettingsSearch({ labelKey: 'settings.providers', tab: 'notifications', keywords: 'telegram discord email notification providers webhook', anchor: 'card-providers' });
 registerSettingsSearch({ labelKey: 'settings.messageTemplates', tab: 'notifications', keywords: 'message templates notification text edit', anchor: 'card-templates' });
 registerSettingsSearch({ labelKey: 'settings.defaultPrintOptions', labelFallback: 'Default Print Options', tab: 'queue', keywords: 'print bed leveling flow calibration vibration first layer timelapse', anchor: 'card-print-options' });
@@ -167,6 +178,23 @@ const STORAGE_FALLBACK_COLORS = [
 const getStorageColor = (key: string, index: number) =>
   STORAGE_CATEGORY_COLORS[key] || STORAGE_FALLBACK_COLORS[index % STORAGE_FALLBACK_COLORS.length];
 
+const LOCATION_SENSOR_CATEGORY_ORDER: Record<string, number> = {
+  temperature: 0,
+  humidity: 1,
+  battery: 2,
+};
+
+// Reads the live readings endpoint (reachable-aware) rather than the sensor
+// row's last_state, so a sensor Home Assistant has stopped reporting shows
+// "Unavailable" instead of silently keeping its last value on screen forever.
+function describeLocationSensorValue(
+  reading: LocationHASensorReading | undefined,
+  t: (key: string, opts?: Record<string, unknown>) => string
+): string | null {
+  if (!reading) return null;
+  return describeHASensorReading(reading, t, { decimals: 2 });
+}
+
 export function SettingsPage() {
   const queryClient = useQueryClient();
   const navigate = useNavigate();
@@ -193,6 +221,19 @@ export function SettingsPage() {
   const [editingPlug, setEditingPlug] = useState<SmartPlug | null>(null);
   const [showHASensorModal, setShowHASensorModal] = useState(false);
   const [editingHASensor, setEditingHASensor] = useState<PrinterHASensor | null>(null);
+  const [showLocationHASensorModal, setShowLocationHASensorModal] = useState(false);
+  const [editingLocationHASensor, setEditingLocationHASensor] = useState<LocationHASensor | null>(null);
+  const [showLocationSensorOptionsModal, setShowLocationSensorOptionsModal] = useState(false);
+  const {
+    colorize: colorizeLocationSensorValues,
+    aboveColor: locationSensorAboveColor,
+    belowColor: locationSensorBelowColor,
+    optimalColor: locationSensorOptimalColor,
+  } = useLocationSensorColorPrefs();
+  const [deleteLocationSensorsTarget, setDeleteLocationSensorsTarget] = useState<{
+    locationName: string;
+    sensorIds: number[];
+  } | null>(null);
   const [showNotificationModal, setShowNotificationModal] = useState(false);
   const [editingProvider, setEditingProvider] = useState<NotificationProvider | null>(null);
   const [editingTemplate, setEditingTemplate] = useState<NotificationTemplate | null>(null);
@@ -453,7 +494,73 @@ export function SettingsPage() {
   const { data: haSensors } = useQuery({
     queryKey: ['haSensors'],
     queryFn: () => api.getHASensors(),
-    enabled: activeTab === 'plugs',
+  });
+
+  // Not polled — this is the sensor list (for the badge count and the
+  // section below), not live readings. It only changes via create/edit/
+  // delete, which already invalidate this key.
+  const { data: locationHaSensors } = useQuery({
+    queryKey: ['locationHaSensors'],
+    queryFn: () => api.getLocationHASensors(),
+  });
+
+  const { data: haSensorLocations } = useQuery({
+    queryKey: inventoryLocationsQueryKey,
+    queryFn: api.getLocations,
+    enabled: activeTab === 'sensors',
+  });
+
+  const locationSensorLocationIds = useMemo(
+    () => Array.from(new Set((locationHaSensors ?? []).map((s) => s.location_id))),
+    [locationHaSensors]
+  );
+
+  // Live readings (reachable-aware), fetched per location the same way
+  // InventoryPage's table view and SpoolLocationFooter do — false to get
+  // every bound sensor here, not just the ones marked to show on the
+  // filament card. Same query key as those two (no 'all'/'cardOnly' suffix),
+  // so navigating here after Inventory has already fetched a location is a
+  // cache hit instead of a second request; navigating back does the same
+  // for Inventory. Only runs while this tab is actually open.
+  const locationSensorReadingsQueries = useQueries({
+    queries: locationSensorLocationIds.map((locationId) => ({
+      queryKey: ['locationHaSensorReadings', locationId],
+      queryFn: () => api.getLocationHASensorReadings(locationId, false),
+      enabled: activeTab === 'sensors',
+      refetchInterval: activeTab === 'sensors' ? (settings?.location_sensor_poll_interval || 120) * 1000 : false,
+    })),
+  });
+
+  const locationSensorReadingsById = useMemo(() => {
+    const map = new Map<number, LocationHASensorReading>();
+    for (const query of locationSensorReadingsQueries) {
+      for (const reading of query.data ?? []) map.set(reading.id, reading);
+    }
+    return map;
+  }, [locationSensorReadingsQueries]);
+
+  const deleteLocationSensorsMutation = useMutation({
+    mutationFn: async (sensorIds: number[]) => {
+      for (const id of sensorIds) {
+        await api.deleteLocationHASensor(id);
+      }
+    },
+    onSuccess: () => {
+      showToast(t('locationHaSensors.toast.deleted'), 'success');
+    },
+    onError: (err: Error) => {
+      showToast(err.message, 'error');
+    },
+    // The mutation deletes sequentially; a failure partway through has
+    // already deleted some sensors on the backend, so the cache needs
+    // refreshing whether the mutation as a whole succeeded or failed —
+    // otherwise the ones that did go through stay on screen as if nothing
+    // happened.
+    onSettled: () => {
+      queryClient.invalidateQueries({ queryKey: ['locationHaSensors'] });
+      queryClient.invalidateQueries({ queryKey: ['locationHaSensorReadings'] });
+      setDeleteLocationSensorsTarget(null);
+    },
   });
 
   // A business-sized fleet gets the commercial ask instead of the donation ask.
@@ -1486,6 +1593,22 @@ export function SettingsPage() {
             </span>
           )}
         </button>
+        <button
+          onClick={() => handleTabChange('sensors')}
+          className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${
+            activeTab === 'sensors'
+              ? 'text-bambu-green border-bambu-green'
+              : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
+          }`}
+        >
+          <Gauge className="w-4 h-4" />
+          {t('settings.tabs.sensors')}
+          {(haSensors?.length ?? 0) + (locationHaSensors?.length ?? 0) > 0 && (
+            <span className="text-xs bg-bambu-dark-tertiary px-1.5 py-0.5 rounded-full">
+              {(haSensors?.length ?? 0) + (locationHaSensors?.length ?? 0)}
+            </span>
+          )}
+        </button>
         <button
           onClick={() => handleTabChange('notifications')}
           className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${
@@ -3690,11 +3813,12 @@ export function SettingsPage() {
               </CardContent>
             </Card>
           )}
+        </div>
+      )}
 
-          {/* Home Assistant sensors (#1148, #448). Sits under the plugs on the
-              same tab: same integration, same credentials, but read-only —
-              these are contacts and thermometers, not switches. */}
-          <div className="mt-8">
+      {activeTab === 'sensors' && (
+        <div id="card-sensors">
+          <div id="card-ha-sensors">
             <div className="flex items-center justify-between mb-4">
               <h2 className="text-lg font-semibold text-white flex items-center gap-2">
                 <Gauge className="w-5 h-5 text-bambu-green" />
@@ -3772,6 +3896,174 @@ export function SettingsPage() {
               </Card>
             )}
           </div>
+
+          <div id="card-location-sensors" className="mt-8">
+            <div className="flex items-start justify-between gap-[20px] mb-4">
+              <div>
+                <h2 className="text-lg font-semibold text-white flex items-center gap-2">
+                  <Gauge className="w-5 h-5 text-bambu-green" />
+                  {t('locationHaSensors.sectionTitle')}
+                </h2>
+                <p className="text-sm text-bambu-gray mt-1">
+                  {t('locationHaSensors.sectionDescription')}
+                </p>
+              </div>
+              <div className="flex items-center gap-2 pt-1 shrink-0">
+                <button
+                  type="button"
+                  onClick={() => setShowLocationSensorOptionsModal(true)}
+                  className="p-2 rounded-lg bg-bambu-dark-tertiary hover:bg-bambu-gray-dark text-white transition-colors"
+                  title={t('locationHaSensors.options.buttonLabel')}
+                  aria-label={t('locationHaSensors.options.buttonLabel')}
+                >
+                  <Cog className="w-4 h-4" />
+                </button>
+                <Button
+                  className="whitespace-nowrap"
+                  disabled={!haSensorLocations?.length}
+                  onClick={() => {
+                    setEditingLocationHASensor(null);
+                    setShowLocationHASensorModal(true);
+                  }}
+                >
+                  <Plus className="w-4 h-4" />
+                  {t('locationHaSensors.add')}
+                </Button>
+              </div>
+            </div>
+
+            {locationHaSensors && locationHaSensors.length > 0 ? (
+              <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
+                {(() => {
+                  // Card order follows haSensorLocations (already sorted
+                  // naturally by the backend — "Drybox 2" before "Drybox
+                  // 10"), not the Map's insertion order, which is whatever
+                  // order the sensors themselves happened to be created in.
+                  const locationOrder = new Map((haSensorLocations ?? []).map((l, i) => [l.id, i]));
+                  const grouped = locationHaSensors.reduce((map, sensor) => {
+                    const list = map.get(sensor.location_id) ?? [];
+                    list.push(sensor);
+                    map.set(sensor.location_id, list);
+                    return map;
+                  }, new Map<number, LocationHASensor[]>());
+                  return Array.from(grouped.entries()).sort(
+                    ([a], [b]) =>
+                      (locationOrder.get(a) ?? Number.MAX_SAFE_INTEGER) -
+                      (locationOrder.get(b) ?? Number.MAX_SAFE_INTEGER)
+                  );
+                })().map(([locationId, unsortedSensors]) => {
+                  const location = haSensorLocations?.find((l) => l.id === locationId);
+                  const iconForSensor = (sensor: LocationHASensor) =>
+                    iconForHASensor({ device_class: sensor.device_class, state: sensor.last_state, kind: sensor.kind });
+                  const sensors = [...unsortedSensors].sort(
+                    (a, b) =>
+                      (LOCATION_SENSOR_CATEGORY_ORDER[a.device_class ?? ''] ?? 99) -
+                      (LOCATION_SENSOR_CATEGORY_ORDER[b.device_class ?? ''] ?? 99)
+                  );
+                  return (
+                    <Card key={locationId}>
+                      <CardContent className="py-4">
+                        <div className="flex items-start justify-between gap-2">
+                          <div className="text-white font-medium truncate">
+                            {location?.name ?? t('locationHaSensors.unknownLocation')}
+                          </div>
+                          <button
+                            type="button"
+                            onClick={() =>
+                              setDeleteLocationSensorsTarget({
+                                locationName: location?.name ?? t('locationHaSensors.unknownLocation'),
+                                sensorIds: sensors.map((sensor) => sensor.id),
+                              })
+                            }
+                            className="p-1 text-bambu-gray hover:text-red-500 rounded transition-colors shrink-0"
+                            title={t('common.delete')}
+                            aria-label={t('common.delete')}
+                          >
+                            <Trash2 className="w-3.5 h-3.5" />
+                          </button>
+                        </div>
+                        <div className="mt-2 space-y-1.5">
+                          {sensors.map((sensor) => {
+                            const Icon = iconForSensor(sensor);
+                            const reading = locationSensorReadingsById.get(sensor.id);
+                            const value = describeLocationSensorValue(reading, t);
+                            const alertStatus =
+                              colorizeLocationSensorValues && reading ? locationSensorReadingAlertStatus(reading) : null;
+                            const valueColor =
+                              locationSensorValueColorClass(
+                                alertStatus,
+                                locationSensorAboveColor,
+                                locationSensorBelowColor,
+                                locationSensorOptimalColor
+                              ) || 'text-white';
+                            return (
+                              <div key={sensor.id} className="flex items-center min-w-0 text-xs text-bambu-gray">
+                                <Icon className="w-3.5 h-3.5 shrink-0 mr-1.5" />
+                                {value && <span className={`${valueColor} shrink-0 w-[52px] text-center`}>{value}</span>}
+                                {value && <span className="shrink-0 mr-1.5">-</span>}
+                                <span className="truncate mr-1.5" title={sensor.name}>
+                                  {sensor.entity_id}
+                                </span>
+                                <button
+                                  type="button"
+                                  onClick={() => {
+                                    setEditingLocationHASensor(sensor);
+                                    setShowLocationHASensorModal(true);
+                                  }}
+                                  className="p-1 text-bambu-gray hover:text-white rounded transition-colors shrink-0"
+                                  title={t('common.edit')}
+                                  aria-label={t('common.edit')}
+                                >
+                                  <Pencil className="w-3.5 h-3.5" />
+                                </button>
+                              </div>
+                            );
+                          })}
+                        </div>
+                        <div className="flex flex-wrap gap-1 mt-3">
+                          {(() => {
+                            const notifying = sensors.filter((sensor) => sensor.notify_on_alert);
+                            if (!notifying.length) return null;
+                            return (
+                              <span className="flex items-center gap-1 px-2 py-0.5 text-xs rounded bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400">
+                                {t('haSensors.badgeNotifies')}
+                                {notifying.map((sensor) => {
+                                  const Icon = iconForSensor(sensor);
+                                  return <Icon key={sensor.id} className="w-3 h-3" />;
+                                })}
+                              </span>
+                            );
+                          })()}
+                          {(() => {
+                            const hidden = sensors.filter((sensor) => !sensor.show_on_card);
+                            if (!hidden.length) return null;
+                            return (
+                              <span className="flex items-center gap-1 px-2 py-0.5 text-xs rounded bg-bambu-dark-tertiary text-bambu-gray">
+                                {t('haSensors.badgeHidden')}
+                                {hidden.map((sensor) => {
+                                  const Icon = iconForSensor(sensor);
+                                  return <Icon key={sensor.id} className="w-3 h-3" />;
+                                })}
+                              </span>
+                            );
+                          })()}
+                        </div>
+                      </CardContent>
+                    </Card>
+                  );
+                })}
+              </div>
+            ) : (
+              <Card>
+                <CardContent className="py-8">
+                  <div className="text-center text-bambu-gray">
+                    <Gauge className="w-12 h-12 mx-auto mb-3 opacity-30" />
+                    <p className="text-sm">{t('locationHaSensors.empty')}</p>
+                  </div>
+                </CardContent>
+              </Card>
+            )}
+          </div>
         </div>
       )}
 
@@ -5908,6 +6200,35 @@ export function SettingsPage() {
         />
       )}
 
+      {showLocationHASensorModal && (
+        <LocationHASensorModal
+          sensor={editingLocationHASensor}
+          locations={haSensorLocations ?? []}
+          onClose={() => {
+            setShowLocationHASensorModal(false);
+            setEditingLocationHASensor(null);
+          }}
+        />
+      )}
+
+      {showLocationSensorOptionsModal && (
+        <LocationSensorOptionsModal onClose={() => setShowLocationSensorOptionsModal(false)} />
+      )}
+
+      {deleteLocationSensorsTarget && (
+        <ConfirmModal
+          title={t('locationHaSensors.deleteAllConfirm.title')}
+          message={t('locationHaSensors.deleteAllConfirm.message', {
+            location: deleteLocationSensorsTarget.locationName,
+            count: deleteLocationSensorsTarget.sensorIds.length,
+          })}
+          variant="danger"
+          isLoading={deleteLocationSensorsMutation.isPending}
+          onConfirm={() => deleteLocationSensorsMutation.mutate(deleteLocationSensorsTarget.sensorIds)}
+          onCancel={() => setDeleteLocationSensorsTarget(null)}
+        />
+      )}
+
       {/* Notification Modal */}
       {showNotificationModal && (
         <AddNotificationModal

+ 121 - 0
frontend/src/utils/haSensorDisplay.ts

@@ -0,0 +1,121 @@
+import {
+  Activity,
+  AlertTriangle,
+  Battery,
+  DoorClosed,
+  DoorOpen,
+  Droplets,
+  Gauge,
+  Lock,
+  LockOpen,
+  Thermometer,
+  Wind,
+} from 'lucide-react';
+import type { LucideIcon } from 'lucide-react';
+
+/**
+ * Display metadata for Home Assistant sensors, shared between the printer
+ * and storage-location bindings. Both features read the same device classes
+ * off the same kind of entity, so keeping one copy here means a new class
+ * only needs adding in one place instead of drifting across every consumer.
+ *
+ * Merging the printer's map with the location one added `battery` here,
+ * which the printer's own map never had — a battery-class printer sensor
+ * used to fall back to the generic Gauge icon. It now gets the Battery icon
+ * too. Deliberate: nothing about a battery-class reading is printer- or
+ * location-specific, and Gauge was an omission in the original map rather
+ * than a considered choice, so there's no reason to carve out an exception
+ * just to preserve it.
+ */
+
+// Home Assistant's own device_class decides the wording, so a door reads
+// "Open"/"Closed" rather than the "on"/"off" the API actually carries. Classes
+// absent from this map fall through to on/off, which is what HA itself shows
+// for a binary_sensor with no class.
+export const HA_SENSOR_BINARY_LABELS: Record<string, { on: string; off: string }> = {
+  door: { on: 'open', off: 'closed' },
+  garage_door: { on: 'open', off: 'closed' },
+  window: { on: 'open', off: 'closed' },
+  opening: { on: 'open', off: 'closed' },
+  lock: { on: 'unlocked', off: 'locked' },
+  motion: { on: 'detected', off: 'clear' },
+  occupancy: { on: 'detected', off: 'clear' },
+  presence: { on: 'detected', off: 'clear' },
+  smoke: { on: 'detected', off: 'clear' },
+  gas: { on: 'detected', off: 'clear' },
+  moisture: { on: 'wet', off: 'dry' },
+  problem: { on: 'problem', off: 'ok' },
+  safety: { on: 'problem', off: 'ok' },
+  running: { on: 'running', off: 'stopped' },
+};
+
+export const HA_SENSOR_ICONS: Record<string, LucideIcon> = {
+  door: DoorOpen,
+  garage_door: DoorOpen,
+  window: DoorOpen,
+  opening: DoorOpen,
+  lock: LockOpen,
+  temperature: Thermometer,
+  humidity: Droplets,
+  moisture: Droplets,
+  battery: Battery,
+  motion: Activity,
+  occupancy: Activity,
+  presence: Activity,
+  smoke: AlertTriangle,
+  gas: AlertTriangle,
+  problem: AlertTriangle,
+  safety: AlertTriangle,
+  running: Wind,
+};
+
+interface IconableReading {
+  device_class: string | null;
+  state: string | null;
+  kind: 'binary' | 'numeric';
+}
+
+interface DescribableReading {
+  device_class: string | null;
+  state: string | null;
+  kind: 'binary' | 'numeric';
+  value: number | null;
+  unit: string | null;
+  reachable: boolean;
+}
+
+// The printer and location features render a reading's value as text the
+// same way — unavailable text, binary state label, or a numeric value with
+// its unit — and used to each carry their own copy. The one place they
+// genuinely differ is decimal places: the printer row shows a sensor's raw
+// value (e.g. "23.4"), while location sensor cells fix two decimal places
+// so temperature/humidity/battery values line up at a consistent width in a
+// table or card grid (e.g. "23.40", "87.00 %") — a deliberate choice, not an
+// oversight. `decimals` is how a caller opts into that padding; omit it for
+// the printer's raw-value behavior.
+export function describeHASensorReading(
+  reading: DescribableReading,
+  t: (key: string, opts?: Record<string, unknown>) => string,
+  options?: { decimals?: number }
+): string {
+  if (!reading.reachable || reading.state === null) return t('haSensors.unavailable');
+  if (reading.kind === 'numeric') {
+    if (reading.value === null) return reading.state;
+    const formatted = options?.decimals !== undefined ? reading.value.toFixed(options.decimals) : String(reading.value);
+    return reading.unit ? `${formatted} ${reading.unit}` : formatted;
+  }
+  const labels = HA_SENSOR_BINARY_LABELS[reading.device_class ?? ''];
+  const key = labels ? labels[reading.state === 'on' ? 'on' : 'off'] : reading.state;
+  return t(`haSensors.states.${key}`, { defaultValue: key });
+}
+
+// A closed door wants the closed-door glyph — the map is keyed by class, so
+// the two states that have a distinct "off" icon are special-cased here.
+export function iconForHASensor(reading: IconableReading): LucideIcon {
+  const deviceClass = reading.device_class ?? '';
+  if (reading.state === 'off') {
+    if (HA_SENSOR_ICONS[deviceClass] === DoorOpen) return DoorClosed;
+    if (HA_SENSOR_ICONS[deviceClass] === LockOpen) return Lock;
+  }
+  return HA_SENSOR_ICONS[deviceClass] ?? (reading.kind === 'numeric' ? Gauge : Activity);
+}

+ 279 - 0
frontend/src/utils/locationSensorDefaults.ts

@@ -0,0 +1,279 @@
+import { useEffect, useState } from 'react';
+import type { LocationHASensorReading } from '../api/client';
+
+export type LocationSensorCategory = 'temperature' | 'humidity' | 'battery';
+
+export interface LocationSensorCategoryDefaults {
+  alertAbove: string;
+  alertBelow: string;
+  notifyOnAlert: boolean;
+  showOnCard: boolean;
+}
+
+export type LocationSensorDefaults = Record<LocationSensorCategory, LocationSensorCategoryDefaults>;
+
+// The alert fields (alertAbove/alertBelow/notifyOnAlert) live on the server in
+// the `location_sensor_alert_defaults` setting, not here: they seed the alert
+// rule written onto each sensor row, so two admins binding sensors from
+// different browsers must not seed different rules, and a backup has to carry
+// them. `showOnCard` stays per-browser — show_on_card is decided per sensor and
+// this is only the form's pre-selection, not a rule the installation runs on.
+const SHOW_ON_CARD_STORAGE_KEY = 'bambuddy-location-sensor-show-on-card-defaults';
+
+const EMPTY_CATEGORY_DEFAULTS: LocationSensorCategoryDefaults = {
+  alertAbove: '',
+  alertBelow: '',
+  notifyOnAlert: false,
+  showOnCard: true,
+};
+
+export function defaultLocationSensorDefaults(): LocationSensorDefaults {
+  return {
+    temperature: { ...EMPTY_CATEGORY_DEFAULTS, alertAbove: '30', alertBelow: '20' },
+    humidity: { ...EMPTY_CATEGORY_DEFAULTS, alertAbove: '30', alertBelow: '10' },
+    battery: { ...EMPTY_CATEGORY_DEFAULTS, alertBelow: '10' },
+  };
+}
+
+// Only the three alert fields are read off the server value; anything else in
+// the stored JSON is ignored so a hand-edited setting cannot inject keys.
+type StoredAlertDefaults = Partial<Record<LocationSensorCategory, Partial<LocationSensorCategoryDefaults>>>;
+
+function readShowOnCardDefaults(): Partial<Record<LocationSensorCategory, boolean>> {
+  try {
+    const stored = localStorage.getItem(SHOW_ON_CARD_STORAGE_KEY);
+    return stored ? (JSON.parse(stored) as Partial<Record<LocationSensorCategory, boolean>>) : {};
+  } catch {
+    return {};
+  }
+}
+
+/**
+ * Merge built-in defaults, the server's alert defaults and the local
+ * show-on-card preference into one shape for the forms.
+ *
+ * Pass the `location_sensor_alert_defaults` string from the settings query.
+ * Omitting it (or passing an empty string) yields the built-in defaults, which
+ * is exactly what an installation that has never opened the options dialog
+ * gets — no migration needed.
+ */
+export function loadLocationSensorDefaults(alertDefaultsJson?: string | null): LocationSensorDefaults {
+  const defaults = defaultLocationSensorDefaults();
+
+  if (alertDefaultsJson) {
+    try {
+      const parsed = JSON.parse(alertDefaultsJson) as StoredAlertDefaults;
+      (Object.keys(defaults) as LocationSensorCategory[]).forEach((category) => {
+        const stored = parsed[category];
+        if (!stored) return;
+        if (typeof stored.alertAbove === 'string') defaults[category].alertAbove = stored.alertAbove;
+        if (typeof stored.alertBelow === 'string') defaults[category].alertBelow = stored.alertBelow;
+        if (typeof stored.notifyOnAlert === 'boolean') defaults[category].notifyOnAlert = stored.notifyOnAlert;
+      });
+    } catch {
+      // A corrupted setting falls back to the built-ins rather than blocking
+      // the dialog — same posture as the localStorage readers below.
+    }
+  }
+
+  const showOnCard = readShowOnCardDefaults();
+  (Object.keys(defaults) as LocationSensorCategory[]).forEach((category) => {
+    if (typeof showOnCard[category] === 'boolean') defaults[category].showOnCard = showOnCard[category];
+  });
+
+  return defaults;
+}
+
+/** The value to PATCH into `location_sensor_alert_defaults`. */
+export function serializeLocationSensorAlertDefaults(defaults: LocationSensorDefaults): string {
+  const out: StoredAlertDefaults = {};
+  (Object.keys(defaults) as LocationSensorCategory[]).forEach((category) => {
+    out[category] = {
+      alertAbove: defaults[category].alertAbove,
+      alertBelow: defaults[category].alertBelow,
+      notifyOnAlert: defaults[category].notifyOnAlert,
+    };
+  });
+  return JSON.stringify(out);
+}
+
+export function saveLocationSensorShowOnCardDefaults(defaults: LocationSensorDefaults) {
+  try {
+    const out: Partial<Record<LocationSensorCategory, boolean>> = {};
+    (Object.keys(defaults) as LocationSensorCategory[]).forEach((category) => {
+      out[category] = defaults[category].showOnCard;
+    });
+    localStorage.setItem(SHOW_ON_CARD_STORAGE_KEY, JSON.stringify(out));
+  } catch {
+    return;
+  }
+}
+
+const COLORIZE_VALUES_STORAGE_KEY = 'bambuddy-location-sensor-colorize-values';
+
+export function loadLocationSensorColorizeValues(): boolean {
+  try {
+    const stored = localStorage.getItem(COLORIZE_VALUES_STORAGE_KEY);
+    return stored ? stored === 'true' : true;
+  } catch {
+    return true;
+  }
+}
+
+// A plain localStorage.setItem never fires the browser's own 'storage'
+// event in the tab that made the change (only other tabs get that), and
+// these four values are read by an unknown number of already-mounted
+// components (the Inventory page, and one SpoolLocationFooter per card).
+// This lets useLocationSensorColorPrefs below re-read after a save instead
+// of every reader needing its own poll or the page needing a reload.
+const COLOR_PREFS_CHANGED_EVENT = 'bambuddy:location-sensor-color-prefs-changed';
+
+function notifyLocationSensorColorPrefsChanged() {
+  window.dispatchEvent(new Event(COLOR_PREFS_CHANGED_EVENT));
+}
+
+export function saveLocationSensorColorizeValues(value: boolean) {
+  try {
+    localStorage.setItem(COLORIZE_VALUES_STORAGE_KEY, String(value));
+  } catch {
+    return;
+  }
+  notifyLocationSensorColorPrefsChanged();
+}
+
+export const LOCATION_SENSOR_ALERT_COLORS = ['red', 'orange', 'yellow', 'green', 'blue', 'purple', 'pink'] as const;
+export type LocationSensorAlertColor = (typeof LOCATION_SENSOR_ALERT_COLORS)[number];
+
+export const LOCATION_SENSOR_ALERT_COLOR_CLASSES: Record<LocationSensorAlertColor, string> = {
+  red: 'text-red-400',
+  orange: 'text-orange-400',
+  yellow: 'text-yellow-400',
+  green: 'text-green-400',
+  blue: 'text-blue-400',
+  purple: 'text-purple-400',
+  pink: 'text-pink-400',
+};
+
+const ALERT_ABOVE_COLOR_STORAGE_KEY = 'bambuddy-location-sensor-alert-above-color';
+const ALERT_BELOW_COLOR_STORAGE_KEY = 'bambuddy-location-sensor-alert-below-color';
+const ALERT_OPTIMAL_COLOR_STORAGE_KEY = 'bambuddy-location-sensor-alert-optimal-color';
+
+function isAlertColor(value: string | null): value is LocationSensorAlertColor {
+  return !!value && (LOCATION_SENSOR_ALERT_COLORS as readonly string[]).includes(value);
+}
+
+export function loadLocationSensorAlertAboveColor(): LocationSensorAlertColor {
+  try {
+    const stored = localStorage.getItem(ALERT_ABOVE_COLOR_STORAGE_KEY);
+    return isAlertColor(stored) ? stored : 'purple';
+  } catch {
+    return 'purple';
+  }
+}
+
+export function saveLocationSensorAlertAboveColor(color: LocationSensorAlertColor) {
+  try {
+    localStorage.setItem(ALERT_ABOVE_COLOR_STORAGE_KEY, color);
+  } catch {
+    return;
+  }
+  notifyLocationSensorColorPrefsChanged();
+}
+
+export function loadLocationSensorAlertBelowColor(): LocationSensorAlertColor {
+  try {
+    const stored = localStorage.getItem(ALERT_BELOW_COLOR_STORAGE_KEY);
+    return isAlertColor(stored) ? stored : 'red';
+  } catch {
+    return 'red';
+  }
+}
+
+export function saveLocationSensorAlertBelowColor(color: LocationSensorAlertColor) {
+  try {
+    localStorage.setItem(ALERT_BELOW_COLOR_STORAGE_KEY, color);
+  } catch {
+    return;
+  }
+  notifyLocationSensorColorPrefsChanged();
+}
+
+export function loadLocationSensorAlertOptimalColor(): LocationSensorAlertColor {
+  try {
+    const stored = localStorage.getItem(ALERT_OPTIMAL_COLOR_STORAGE_KEY);
+    return isAlertColor(stored) ? stored : 'green';
+  } catch {
+    return 'green';
+  }
+}
+
+export function saveLocationSensorAlertOptimalColor(color: LocationSensorAlertColor) {
+  try {
+    localStorage.setItem(ALERT_OPTIMAL_COLOR_STORAGE_KEY, color);
+  } catch {
+    return;
+  }
+  notifyLocationSensorColorPrefsChanged();
+}
+
+export interface LocationSensorColorPrefs {
+  colorize: boolean;
+  aboveColor: LocationSensorAlertColor;
+  belowColor: LocationSensorAlertColor;
+  optimalColor: LocationSensorAlertColor;
+}
+
+function readLocationSensorColorPrefs(): LocationSensorColorPrefs {
+  return {
+    colorize: loadLocationSensorColorizeValues(),
+    aboveColor: loadLocationSensorAlertAboveColor(),
+    belowColor: loadLocationSensorAlertBelowColor(),
+    optimalColor: loadLocationSensorAlertOptimalColor(),
+  };
+}
+
+// Reads the four location-sensor colour preferences once and stays live:
+// a Settings save dispatches COLOR_PREFS_CHANGED_EVENT, and every mounted
+// caller of this hook (the Inventory page, previously also every
+// SpoolLocationFooter individually) picks it up without a reload. Callers
+// should read these values here and pass them down as props rather than
+// each calling this hook themselves — one subscription per page, not one
+// per card.
+export function useLocationSensorColorPrefs(): LocationSensorColorPrefs {
+  const [prefs, setPrefs] = useState<LocationSensorColorPrefs>(readLocationSensorColorPrefs);
+
+  useEffect(() => {
+    const onChange = () => setPrefs(readLocationSensorColorPrefs());
+    window.addEventListener(COLOR_PREFS_CHANGED_EVENT, onChange);
+    return () => window.removeEventListener(COLOR_PREFS_CHANGED_EVENT, onChange);
+  }, []);
+
+  return prefs;
+}
+
+export type LocationSensorAlertStatus = 'above' | 'below' | 'ok' | null;
+
+export function locationSensorReadingAlertStatus(reading: LocationHASensorReading): LocationSensorAlertStatus {
+  if (!reading.reachable || reading.state === null) return null;
+  if (reading.kind === 'numeric') {
+    if (reading.alert_above === null && reading.alert_below === null) return null;
+    if (reading.value === null) return null;
+    if (reading.alert_above !== null && reading.value > reading.alert_above) return 'above';
+    if (reading.alert_below !== null && reading.value < reading.alert_below) return 'below';
+    return 'ok';
+  }
+  if (reading.alert_state === null) return null;
+  return reading.state.toLowerCase() === reading.alert_state ? 'above' : 'ok';
+}
+
+export function locationSensorValueColorClass(
+  status: LocationSensorAlertStatus,
+  aboveColor: LocationSensorAlertColor,
+  belowColor: LocationSensorAlertColor,
+  optimalColor: LocationSensorAlertColor
+): string {
+  if (status === 'above') return LOCATION_SENSOR_ALERT_COLOR_CLASSES[aboveColor];
+  if (status === 'below') return LOCATION_SENSOR_ALERT_COLOR_CLASSES[belowColor];
+  if (status === 'ok') return LOCATION_SENSOR_ALERT_COLOR_CLASSES[optimalColor];
+  return '';
+}

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-Bo6-nt-q.js


Разница между файлами не показана из-за своего большого размера
+ 1 - 0
static/assets/index-C7cOM7tZ.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 1
static/assets/index-DjndScv6.css


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-CA21Tb7f.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-DjndScv6.css">
+    <script type="module" crossorigin src="/assets/index-Bo6-nt-q.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-C7cOM7tZ.css">
   </head>
   <body>
     <div id="root"></div>

Некоторые файлы не были показаны из-за большого количества измененных файлов