Show Home Assistant sensors on the printer card (#1148, #448)

Binds binary_sensor and reading-carrying sensor entities to a printer and
renders their state on its card, worded by Home Assistant's device_class.
Optional per-sensor alert condition drives a notification on the transition
into the alert state and an opt-in interlock that holds queued prints while
alerting -- a hold with a readable waiting_reason, never a failure, and only
ever on a sensor that was read successfully.

Sensors get their own table rather than a wider entity pattern on SmartPlug:
get_smart_plug_by_printer would otherwise hand the card's power button a door
contact to switch.

The hold is passed to the model matcher directly rather than merged into
busy_printers: _check_auto_drying reads that set as "is currently printing"
and would put an idle-but-held printer down the mid-print drying path.

The notification_providers migration spells its default FALSE, not 0 --
Postgres rejects an integer default for a boolean and _safe_execute swallows
the error.
This commit is contained in:
maziggy 2026-08-05 14:26:38 +02:00
parent 945d4ca6eb
commit cd004df817
45 changed files with 3782 additions and 172 deletions

View file

@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
## [1.2.6b1] - Unreleased
### Added
- **Home Assistant sensors on the printer card, with an optional print interlock (#1148, reporter @bsaunder; #448, reporter @baudneo)** — Bambuddy could already switch a Home Assistant entity as a smart plug, but it had no way to *read* one. A printer in a home-built enclosure with a door contact, or an A1 with an aftermarket chamber thermometer, had all that data in Home Assistant and none of it in Bambuddy — the reporter's actual problem being that he could not tell whether he had left the enclosure open before starting a print from his phone. **Settings → Smart Plugs → Home Assistant Sensors** now binds any `binary_sensor` — a door, window, smoke or moisture contact — or any `sensor` that carries a reading to a printer, and its state appears on that printer's card. The wording follows Home Assistant's own device class, so a door reads Open or Closed rather than On or Off, and a thermometer reads `41.2 °C`; entities with no device class fall back to on/off, exactly as Home Assistant shows them. A sensor can be given an alert condition — on, off, above a value, below a value — which highlights it on the card and unlocks two things it would otherwise be pointless to offer. **Notify on alert** sends a notification the moment the sensor enters that state, once on the way in rather than on every poll, and not again when a flaky contact drops off the network and comes back still alerting. **Hold prints while alerting** is the part that answers the original question without anyone having to look: queued jobs for that printer wait, with a reason on the Queue page you can read at a glance ("Waiting on Enclosure Door"), and start by themselves as soon as the door shuts. Nothing is ever cancelled, and a job queued as "Any X1C" simply goes to a sibling whose sensors are clear instead of waiting behind the one that is held. The interlock is deliberately one-directional: it holds only on a sensor that was read successfully and *is* alerting, so a Home Assistant that is unreachable holds nothing and the queue keeps running as though no interlock were configured. Sensors are their own thing rather than a smart plug with a wider entity filter — a plug carries auto-on, schedules, power alerts and "controls printer power", and the printer card's power button would have happily tried to switch a door contact. One backend poller reads every bound entity every 15 seconds and the cards serve that cached reading, so the cost to Home Assistant does not grow with the number of printers on screen or browser tabs open. Off by default in every respect: a newly bound sensor is display-only until you give it an alert condition, and both the notification and the interlock are separate opt-ins on top of that.
- **Open a File Manager model in your desktop slicer, and pick which one from the 3D preview (#2725, contributor @pascalheidmann)** — The **Slice** action on a file card only existed when the optional slicer sidecar was running. Turn the sidecar off — which is the default, and how most installs run — and the File Manager offered no way to get a model into a slicer at all, even though the Archives page has handed files to a locally-installed Bambu Studio or OrcaSlicer over the URI scheme for a long time. The File Manager was simply the one place that never got it. **Slice** now appears on every unsliced model (`.3mf`, `.stl`, `.step`, `.stp`) in both the card menu and the list view, and does whichever of the two things your configuration means: with the sidecar on it opens Bambuddy's slice modal and the work happens on the server, and with it off it hands the file to your desktop slicer. The icon says which you will get before you click — a cog for server-side slicing, an external-link arrow for the handoff — and which slicer receives the handoff comes from **Settings → Workflow → Slicer → Open in Slicer**, falling back to the preferred slicer as it always has. The 3D preview goes further, because that is where you are actually looking at the model and deciding: its slicer button is now a split button, and the chevron beside it offers the alternatives without changing any setting. With the sidecar off that is the slicer you did *not* pick as your desktop target; with it on, the primary button still slices server-side and the menu offers a one-off desktop handoff to either slicer. Two things that used to be silent now are not: a handoff refused for want of permission raises an error toast rather than launching the slicer at a URL it cannot fetch, where a permission problem looked exactly like "no slicer installed"; and the file-type rule is shared between the card menu and the 3D preview, so a file can no longer offer **Slice** in one place while showing it greyed out in the other. Permissions follow the endpoint each mode calls — the handoff is a download and needs the same library read permission that lets you see the file, server-side slicing writes a new file and needs upload rights — and the action is shown disabled with the missing permission named rather than hidden. Translated in all locales; wiki updated. Covered by frontend tests.
- **Temperatures on the streaming overlay, and a builder for its URL (#1422, reporter @SMAW)** — The overlay at `/overlay/{printer}` draws live print data over a full-screen camera view for OBS, a wall display or any browser source. It could already be tuned — which fields, what size, what frame rate — but only through query parameters documented in the wiki, and temperatures were not among the fields on offer. Both are now addressed. Nozzle, bed and chamber readings join the list, shown with the target while the heater is still climbing and with the target dropped once it is reached, so a settled hotend reads "220°C" rather than "220 / 220°C" for the rest of the print. Both nozzles appear on a dual-nozzle printer. They are drawn whether or not a print is running, since a preheating machine is exactly when they are worth watching, and each reading appears only when the printer genuinely reports it — chamber temperature stays absent on P1 and A1 models, which publish a value with no sensor behind it. And **Settings → API Keys → Streaming Overlay** now builds the URL for you: pick the printer, tick the fields, set size and frame rate, paste in a token if login is enabled, and copy the result, with an optional preview alongside it. The preview stays off until you ask for it so that leaving the settings page open does not hold a viewer on the printer's single camera connection. Making that preview possible needed one narrow change to the security headers: the overlay path now sends `frame-ancestors 'self'` instead of `'none'`, so Bambuddy's own UI can embed it. Every other page still refuses to be framed at all, `'self'` permits a framer only on this same origin, and embedding the overlay from another host — Home Assistant on a different port, say — is unchanged and still requires `TRUSTED_FRAME_ORIGINS`. Temperatures are not in the default field set, so an overlay URL already pasted into a scene looks exactly the same after upgrading. Translated in all locales, wiki updated, covered by backend and frontend tests.
- **The external spool can be hidden from the printer card (#1782, reporter @Arn0uDz)** — An external spool holder that never gets used still occupies a full card's width in the **Filaments** row, next to the AMS units that are actually being used. An eye icon at the right-hand end of that row's header now hides it, and clicking it again brings it back, so nothing is lost behind a settings page you would have to remember. The choice is remembered per printer and stored in the browser, like the card size and the offline-printer filter — one machine in a fleet can be tidied up without touching the others, and nothing changes for anyone else using the same Bambuddy. The icon is deliberately absent on a printer with no AMS: there the external spool is the entire filament section, and hiding it would leave an empty row. That guard also covers the case of an AMS being unplugged from a printer whose external spool was hidden earlier — the spool reappears rather than leaving a blank row behind. On the H2D and H2S both external positions share one card and so hide together. Translated in all locales, wiki updated, covered by frontend tests.

View file

@ -0,0 +1,239 @@
"""API routes for Home Assistant sensors bound to a printer (#1148, #448)."""
import logging
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.auth import RequirePermissionIfAuthEnabled
from backend.app.core.database import get_db
from backend.app.core.permissions import Permission
from backend.app.models.printer import Printer
from backend.app.models.printer_ha_sensor import PrinterHASensor
from backend.app.models.user import User
from backend.app.schemas.printer_ha_sensor import (
HADisplayEntity,
PrinterHASensorCreate,
PrinterHASensorReading,
PrinterHASensorResponse,
PrinterHASensorUpdate,
)
from backend.app.services.ha_sensor_manager import ha_sensor_manager
from backend.app.services.homeassistant import homeassistant_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/ha-sensors", tags=["ha-sensors"])
# These reuse the smart-plug permissions rather than introducing their own.
# Both surfaces are "the Home Assistant integration", and a brand-new
# permission would be missing from every existing custom role — users who can
# manage plugs today would silently lose access to the sensors next to them.
_READ = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ)
_CREATE = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CREATE)
_UPDATE = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_UPDATE)
_DELETE = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_DELETE)
async def _refresh_quietly(sensor: PrinterHASensor, 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 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[PrinterHASensorResponse])
async def list_ha_sensors(
printer_id: int | None = None,
db: AsyncSession = Depends(get_db),
_: User | None = _READ,
):
"""List configured sensors, grouped by printer and in display order."""
query = select(PrinterHASensor)
if printer_id is not None:
query = query.where(PrinterHASensor.printer_id == printer_id)
result = await db.execute(query.order_by(PrinterHASensor.printer_id, PrinterHASensor.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 printer."""
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-printer/{printer_id}/readings", response_model=list[PrinterHASensorReading])
async def get_printer_sensor_readings(
printer_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = _READ,
):
"""Live state of a printer's card-visible sensors.
Served from the poller's cache, so a page full of printer 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.
"""
result = await db.execute(
select(PrinterHASensor)
.where(
PrinterHASensor.printer_id == printer_id,
PrinterHASensor.show_on_printer_card.is_(True),
)
.order_by(PrinterHASensor.sort_order, PrinterHASensor.id)
)
readings = []
for sensor in result.scalars().all():
cached = ha_sensor_manager.get_reading(sensor.id)
readings.append(
PrinterHASensorReading(
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,
block_print=sensor.block_print,
reachable=cached.reachable if cached else False,
last_changed=sensor.last_changed,
)
)
return readings
@router.post("/", response_model=PrinterHASensorResponse)
async def create_ha_sensor(
data: PrinterHASensorCreate,
db: AsyncSession = Depends(get_db),
_: User | None = _CREATE,
):
"""Bind a Home Assistant entity to a printer."""
printer = await db.get(Printer, data.printer_id)
if not printer:
raise HTTPException(404, "Printer not found")
existing = await db.execute(
select(PrinterHASensor).where(
PrinterHASensor.printer_id == data.printer_id,
PrinterHASensor.entity_id == data.entity_id,
)
)
if existing.scalar_one_or_none():
raise HTTPException(400, f"{data.entity_id} is already bound to this printer")
sensor = PrinterHASensor(**data.model_dump())
db.add(sensor)
await db.commit()
await db.refresh(sensor)
logger.info("Bound HA entity %s to printer %s as '%s'", sensor.entity_id, sensor.printer_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=PrinterHASensorResponse)
async def get_ha_sensor(
sensor_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = _READ,
):
sensor = await db.get(PrinterHASensor, sensor_id)
if not sensor:
raise HTTPException(404, "Sensor not found")
return sensor
@router.patch("/{sensor_id}", response_model=PrinterHASensorResponse)
async def update_ha_sensor(
sensor_id: int,
data: PrinterHASensorUpdate,
db: AsyncSession = Depends(get_db),
_: User | None = _UPDATE,
):
sensor = await db.get(PrinterHASensor, 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 block_print 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 PrinterHASensorCreate.model_fields}
merged.update(updates)
try:
PrinterHASensorCreate(**merged)
except ValueError as e:
raise HTTPException(422, str(e)) from e
# Same uniqueness rule as create: repointing a sensor at an entity the
# printer already has would leave two rows fighting over one pill.
new_entity = updates.get("entity_id")
if new_entity and new_entity != sensor.entity_id:
clash = await db.execute(
select(PrinterHASensor).where(
PrinterHASensor.printer_id == sensor.printer_id,
PrinterHASensor.entity_id == new_entity,
PrinterHASensor.id != sensor.id,
)
)
if clash.scalar_one_or_none():
raise HTTPException(400, f"{new_entity} is already bound to this printer")
for field, value in updates.items():
setattr(sensor, field, value)
await db.commit()
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_ha_sensor(
sensor_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = _DELETE,
):
sensor = await db.get(PrinterHASensor, sensor_id)
if not sensor:
raise HTTPException(404, "Sensor not found")
name = sensor.name
await db.delete(sensor)
await db.commit()
ha_sensor_manager.forget(sensor_id)
logger.info("Removed HA sensor '%s'", name)
return {"message": f"Sensor '{name}' removed"}

View file

@ -279,6 +279,7 @@ async def init_db():
print_log,
print_queue,
printer,
printer_ha_sensor,
printer_sensor_history,
project,
project_bom,
@ -3996,6 +3997,18 @@ async def run_migrations(conn):
)
await _migrate_backfill_variant_groups(conn)
# Migration: Home Assistant sensor alerts (#1148). The printer_ha_sensors
# table itself is new, so create_all() builds it; only the provider opt-in
# column needs adding to existing databases.
#
# DEFAULT FALSE, not DEFAULT 0: Postgres will not take an integer default
# for a boolean column, and _safe_execute swallows the DatatypeMismatchError
# — so the older "BOOLEAN DEFAULT 0" migrations above quietly do nothing on
# Postgres and only work there because create_all() builds the column on a
# fresh install. SQLite has understood FALSE since 3.23, so this spelling
# is the one that actually applies on both.
await _safe_execute(conn, "ALTER TABLE notification_providers ADD COLUMN on_ha_sensor_alert BOOLEAN DEFAULT FALSE")
async def _migrate_backfill_variant_groups(conn) -> None:
"""Build variant groups from the slice provenance already on disk (#671 / #2570).

View file

@ -33,6 +33,7 @@ from backend.app.api.routes import (
firmware,
github_backup,
groups,
ha_sensors,
inventory,
kprofiles,
labels,
@ -96,6 +97,7 @@ from backend.app.services.bambu_ftp import (
)
from backend.app.services.bambu_mqtt import PrinterState
from backend.app.services.github_backup import github_backup_service
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
@ -7344,6 +7346,9 @@ async def lifespan(app: FastAPI):
# Start the smart plug scheduler for time-based on/off
smart_plug_manager.start_scheduler()
# Start the Home Assistant sensor poller (#1148)
ha_sensor_manager.start()
# Resume any pending auto-offs that were interrupted by restart
await smart_plug_manager.resume_pending_auto_offs()
@ -7422,6 +7427,7 @@ async def lifespan(app: FastAPI):
# Shutdown
print_scheduler.stop()
smart_plug_manager.stop_scheduler()
ha_sensor_manager.stop()
notification_service.stop_digest_scheduler()
github_backup_service.stop_scheduler()
local_backup_service.stop_scheduler()
@ -7922,6 +7928,7 @@ app.include_router(cloud.router, prefix=app_settings.api_prefix)
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(print_log.router, prefix=app_settings.api_prefix)
app.include_router(print_queue.router, prefix=app_settings.api_prefix)
app.include_router(kprofiles.router, prefix=app_settings.api_prefix)

View file

@ -21,6 +21,7 @@ from backend.app.models.pending_upload import PendingUpload
from backend.app.models.pipeline_run import PipelineJob, PipelineRun
from backend.app.models.print_batch import PrintBatch, PrintBatchPlate
from backend.app.models.printer import Printer
from backend.app.models.printer_ha_sensor import PrinterHASensor
from backend.app.models.printer_sensor_history import PrinterSensorHistory
from backend.app.models.project import Project
from backend.app.models.settings import Settings
@ -56,6 +57,7 @@ __all__ = [
"APIKey",
"AMSSensorHistory",
"PrinterSensorHistory",
"PrinterHASensor",
"AmsLabel",
"PendingUpload",
"PrintBatch",

View file

@ -82,6 +82,9 @@ class NotificationProvider(Base):
on_ams_ht_humidity_high = Column(Boolean, default=False) # AMS-HT humidity above threshold
on_ams_ht_temperature_high = Column(Boolean, default=False) # AMS-HT temperature above threshold
# 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 - 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)

View file

@ -121,6 +121,12 @@ DEFAULT_TEMPLATES = [
"title_template": "Bed Cooled",
"body_template": "{printer}: Bed cooled to {bed_temp}°C (threshold: {threshold}°C)",
},
{
"event_type": "ha_sensor_alert",
"name": "Home Assistant Sensor Alert",
"title_template": "Sensor Alert",
"body_template": "{printer}: {sensor} is {state}",
},
{
"event_type": "first_layer_complete",
"name": "First Layer Complete",

View file

@ -61,6 +61,7 @@ class Printer(Base):
sensor_history: Mapped[list["PrinterSensorHistory"]] = relationship(
back_populates="printer", cascade="all, delete-orphan"
)
ha_sensors: Mapped[list["PrinterHASensor"]] = relationship(back_populates="printer", cascade="all, delete-orphan")
from backend.app.models.ams_history import AMSSensorHistory # noqa: E402
@ -68,5 +69,6 @@ from backend.app.models.archive import PrintArchive # noqa: E402
from backend.app.models.kprofile_note import KProfileNote # noqa: E402
from backend.app.models.maintenance import PrinterMaintenance # noqa: E402
from backend.app.models.notification import NotificationProvider # noqa: E402
from backend.app.models.printer_ha_sensor import PrinterHASensor # noqa: E402
from backend.app.models.printer_sensor_history import PrinterSensorHistory # noqa: E402
from backend.app.models.smart_plug import SmartPlug # noqa: E402

View file

@ -0,0 +1,72 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.app.core.database import Base
class PrinterHASensor(Base):
"""A read-only Home Assistant entity bound to a printer (#1148, #448).
Deliberately *not* a ``SmartPlug`` row with a wider entity pattern. A plug
carries auto-on/auto-off, schedules, power alerts, energy snapshots and
``controls_printer_power``; none of that means anything for a door contact,
and ``get_smart_plug_by_printer`` would hand the card's power button a
sensor to switch. Sensors get their own table and their own read-only
routes instead.
Not to be confused with ``PrinterSensorHistory``, which stores the
printer's *own* heater readings.
"""
__tablename__ = "printer_ha_sensors"
id: Mapped[int] = mapped_column(primary_key=True)
printer_id: Mapped[int] = mapped_column(ForeignKey("printers.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
# on/off wording (door -> Open/Closed, motion -> Detected/Clear) and the
# icon, so the card doesn't have to say "On" for an open door.
device_class: Mapped[str | None] = mapped_column(String(32), nullable=True)
# Numeric only: "°C", "%", "ppm", ... shown next to the value.
unit: Mapped[str | None] = mapped_column(String(16), nullable=True)
# What counts as needing attention. One notion, three consumers: the pill
# colour on the card, the notification, and the print interlock.
# 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)
# Hold queued prints for this printer while the sensor is in its alert
# state — the enclosure-door case this feature was asked for. Opt-in, and
# only ever a *hold*: the item stays pending with a waiting_reason and
# dispatches by itself once the door closes.
block_print: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
notify_on_alert: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
show_on_printer_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(64), 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())
printer: Mapped["Printer"] = relationship(back_populates="ha_sensors")
from backend.app.models.printer import Printer # noqa: E402

View file

@ -61,6 +61,11 @@ class NotificationProviderBase(BaseModel):
default=False, description="Notify when AMS-HT temperature exceeds threshold"
)
# Event triggers - Home Assistant sensors (#1148)
on_ha_sensor_alert: bool = Field(
default=False, description="Notify when a bound Home Assistant sensor 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(
@ -148,6 +153,9 @@ 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)
on_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

View file

@ -23,6 +23,7 @@ class EventType(StrEnum):
AMS_HUMIDITY_HIGH = "ams_humidity_high"
AMS_TEMPERATURE_HIGH = "ams_temperature_high"
BED_COOLED = "bed_cooled"
HA_SENSOR_ALERT = "ha_sensor_alert"
TEST = "test"
@ -77,6 +78,7 @@ EVENT_VARIABLES: dict[str, list[str]] = {
"ams_humidity_high": ["printer", "ams_label", "humidity", "threshold", "timestamp", "app_name"],
"ams_temperature_high": ["printer", "ams_label", "temperature", "threshold", "timestamp", "app_name"],
"bed_cooled": ["printer", "bed_temp", "threshold", "filename", "timestamp", "app_name"],
"ha_sensor_alert": ["printer", "sensor", "state", "timestamp", "app_name"],
"test": ["app_name", "timestamp"],
# Queue notifications
"queue_job_added": ["job_name", "target", "timestamp", "app_name"],
@ -205,6 +207,13 @@ SAMPLE_DATA: dict[str, dict[str, str]] = {
"timestamp": "2024-01-15 14:30",
"app_name": "Bambuddy",
},
"ha_sensor_alert": {
"printer": "Bambu X1C",
"sensor": "Enclosure Door",
"state": "open",
"timestamp": "2024-01-15 14:30",
"app_name": "Bambuddy",
},
"test": {
"app_name": "Bambuddy",
"timestamp": "2024-01-15 14:30",

View file

@ -0,0 +1,114 @@
"""Schemas for Home Assistant entities bound to a printer (#1148, #448)."""
from datetime import datetime
from typing import Literal
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_]+$")
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
alert_above: float | None = None
alert_below: float | None = None
block_print: bool = False
notify_on_alert: bool = False
show_on_printer_card: bool = True
sort_order: int = Field(default=0, ge=0, le=999)
@model_validator(mode="after")
def validate_kind_matches_entity(self) -> "PrinterHASensorBase":
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 door contact and an
# on/off alert on a thermometer 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")
# An interlock or a notification with nothing to trigger on would never
# fire — that reads as a broken feature, not as a no-op.
if (self.block_print or self.notify_on_alert) and not self._has_alert_condition():
raise ValueError("block_print and notify_on_alert require 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 PrinterHASensorCreate(PrinterHASensorBase):
pass
class PrinterHASensorUpdate(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)
entity_id: str | None = Field(default=None, 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
alert_above: float | None = None
alert_below: float | None = None
block_print: bool | None = None
notify_on_alert: bool | None = None
show_on_printer_card: bool | None = None
sort_order: int | None = Field(default=None, ge=0, le=999)
class PrinterHASensorResponse(PrinterHASensorBase):
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 PrinterHASensorReading(BaseModel):
"""One sensor's live state, as the printer card renders 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
block_print: bool = False
reachable: bool = True
last_changed: datetime | None = None
class HADisplayEntity(BaseModel):
"""A bindable entity, as offered by the picker."""
entity_id: str
friendly_name: str
state: str | None = None
domain: str
device_class: str | None = None
unit_of_measurement: str | None = None

View file

@ -0,0 +1,271 @@
"""Polls the Home Assistant entities bound to printers (#1148, #448).
One background loop reads every configured entity on a fixed cadence and keeps
the result in memory. Three things consume it:
* the printer card, which reads the cache instead of hitting Home Assistant
once per card per refresh;
* notifications, fired on a transition *into* the alert state, never on every
poll while it persists;
* the print interlock, which holds queued jobs for a printer while one of its
sensors is alerting.
Everything degrades to "no opinion" when Home Assistant cannot be reached: an
unreadable sensor never alerts, never notifies, and never holds a print. A
door contact that stops responding must not strand the queue.
"""
import asyncio
import logging
from dataclasses import dataclass
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.services.homeassistant import as_float, homeassistant_service
from backend.app.utils.local_time import utcnow_naive
logger = logging.getLogger(__name__)
# Fast enough that an enclosure door reads as live, slow enough that a handful
# of tiny LAN requests stays background noise.
POLL_INTERVAL = 15
@dataclass
class SensorReading:
"""The last thing we managed to read for one sensor."""
state: str | None # raw HA state, None when unreadable
value: float | None # parsed number for numeric sensors
alerting: bool
reachable: bool
class HASensorManager:
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 contact. Absent means "never had a reachable reading".
self._last_alerting: dict[int, bool] = {}
# -- lifecycle ---------------------------------------------------------
def start(self):
if self._task is None:
self._task = asyncio.create_task(self._poll_loop())
logger.info("Home Assistant sensor poller started")
def stop(self):
if self._task:
self._task.cancel()
self._task = None
logger.info("Home Assistant sensor poller stopped")
# -- cache access ------------------------------------------------------
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 blocked_printers(self, db: AsyncSession) -> dict[int, str]:
"""Printers currently held by an interlock, mapped to the sensor names.
A sensor counts only when it is configured to block, *and* was read
successfully, *and* is in its alert state. Anything we could not read
is omitted, so the queue keeps moving when Home Assistant is down.
One query for the whole fleet the scheduler calls this on every pass,
and per-printer lookups would put a query per printer in that loop.
"""
result = await db.execute(select(PrinterHASensor).where(PrinterHASensor.block_print.is_(True)))
blocked: dict[int, list[str]] = {}
for sensor in result.scalars().all():
reading = self._readings.get(sensor.id)
if reading and reading.reachable and reading.alerting:
blocked.setdefault(sensor.printer_id, []).append(sensor.name)
return {printer_id: ", ".join(names) for printer_id, names in blocked.items()}
# -- polling -----------------------------------------------------------
async def _poll_loop(self):
while True:
try:
await asyncio.sleep(POLL_INTERVAL)
await self.poll_once()
except asyncio.CancelledError:
break
except Exception as e:
logger.warning("Home Assistant sensor poll failed: %s", e)
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(PrinterHASensor))
sensors = list(result.scalars().all())
# Drop readings for rows that no longer exist. The delete route
# calls forget(), but a printer 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):
# Not configured is not a failure to report every 15 seconds,
# but the readings must not go stale-but-confident either.
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: PrinterHASensor):
"""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()
if reading.reachable and sensor.last_state != reading.state:
sensor.last_state = reading.state
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[PrinterHASensor], 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[PrinterHASensor, 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:
if sensor.last_state != reading.state:
sensor.last_state = reading.state
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 door that was
# already open then has not just been opened, 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.printer: touching the lazy relationship from
# an async session raises MissingGreenlet.
printer = await db.get(Printer, sensor.printer_id)
try:
await notification_service.on_ha_sensor_alert(
printer_id=sensor.printer_id,
printer_name=printer.name if printer 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)
def evaluate(sensor: PrinterHASensor, 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
poller, a database or a Home Assistant.
"""
if payload is None:
return SensorReading(state=None, value=None, alerting=False, reachable=False)
state = payload.get("state")
# HA reports these two for entities whose integration is down. Treating
# them as a state would make "unavailable" a value the card renders and
# the thresholds compare against.
if state in (None, "unknown", "unavailable"):
return SensorReading(state=None, value=None, alerting=False, reachable=False)
state = str(state)
if sensor.kind == "numeric":
value = as_float(state)
if value is None:
# A sensor that used to report numbers and now reports text is
# not a reading we can place against a threshold.
return SensorReading(state=state, value=None, alerting=False, reachable=True)
alerting = (sensor.alert_above is not None and value > sensor.alert_above) or (
sensor.alert_below is not None and value < sensor.alert_below
)
return SensorReading(state=state, value=value, alerting=alerting, reachable=True)
normalized = state.lower()
alerting = sensor.alert_state is not None and normalized == sensor.alert_state
return SensorReading(state=normalized, value=None, alerting=alerting, reachable=True)
def describe_state(sensor: PrinterHASensor, 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}"
return reading.state or "unknown"
ha_sensor_manager = HASensorManager()

View file

@ -1,5 +1,6 @@
"""Service for communicating with Home Assistant via REST API."""
import asyncio
import logging
from typing import TYPE_CHECKING
from urllib.parse import urlparse
@ -364,6 +365,107 @@ class HomeAssistantService:
logger.warning("Failed to list HA sensor entities: %s", e)
return []
async def list_display_entities(self, url: str, token: str, search: str | None = None) -> list[dict]:
"""List entities that can be bound to a printer for display (#1148, #448).
Covers every ``binary_sensor.*`` plus the ``sensor.*`` entities that
carry a reading. Distinct from ``list_sensor_entities``, which exists
for a plug's energy monitoring and therefore only admits power/energy
units an enclosure thermometer is exactly what that one filters out.
A ``sensor.*`` qualifies when it has a unit or its state parses as a
number. That drops the text sensors (``sensor.washing_machine_status``)
that the card has no way to render as a value.
"""
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{url.rstrip('/')}/api/states",
headers={"Authorization": f"Bearer {token}"},
)
response.raise_for_status()
entities = []
search_lower = search.lower().strip() if search else None
for entity in response.json():
entity_id = entity.get("entity_id", "")
domain = entity_id.split(".")[0] if "." in entity_id else ""
if domain not in ("binary_sensor", "sensor"):
continue
attrs = entity.get("attributes", {})
unit = attrs.get("unit_of_measurement")
state = entity.get("state")
if domain == "sensor" and not unit and as_float(state) is None:
continue
friendly_name = attrs.get("friendly_name") or entity_id
if search_lower and (
search_lower not in entity_id.lower() and search_lower not in friendly_name.lower()
):
continue
entities.append(
{
"entity_id": entity_id,
"friendly_name": friendly_name,
"state": state,
"domain": domain,
"device_class": attrs.get("device_class"),
"unit_of_measurement": unit,
}
)
return sorted(entities, key=lambda x: x["friendly_name"].lower())
except Exception as e:
logger.warning("Failed to list HA display entities: %s", e)
return []
async def fetch_states(self, entity_ids: list[str]) -> dict[str, dict | None]:
"""Read several entities in one pass, keyed by entity_id.
One GET per entity over a shared client rather than a single
``/api/states`` sweep: the poller only ever wants a handful of bound
entities, and pulling every state in the user's Home Assistant on a
15-second cadence is a lot of payload to throw away.
A ``None`` value means that entity could not be read the callers
treat that as "no opinion" rather than as a state, so an unreachable
Home Assistant never trips an alert or holds a print.
"""
if not entity_ids:
return {}
if not self.base_url or not self.token:
return dict.fromkeys(entity_ids)
async with httpx.AsyncClient(timeout=self.timeout) as client:
async def _one(entity_id: str) -> tuple[str, dict | None]:
try:
response = await client.get(
f"{self.base_url}/api/states/{entity_id}",
headers=self._headers(),
)
response.raise_for_status()
return entity_id, response.json()
except Exception as e:
logger.debug("Failed to read HA entity %s: %s", entity_id, e)
return entity_id, None
results = await asyncio.gather(*(_one(e) for e in entity_ids))
return dict(results)
def as_float(value) -> float | None:
"""Parse a HA state to a number, or None for "unknown"/"unavailable"/text."""
try:
return float(value)
except (TypeError, ValueError):
return None
# Singleton instance
homeassistant_service = HomeAssistantService()

View file

@ -1729,6 +1729,43 @@ class NotificationService:
providers, title, message, db, "bed_cooled", printer_id, printer_name, variables=variables
)
async def on_ha_sensor_alert(
self,
printer_id: int,
printer_name: str,
sensor_name: str,
state: str,
db: AsyncSession,
):
"""A Home Assistant sensor bound to a printer entered its alert state (#1148).
Sent immediately rather than folded into a digest: the case this exists
for is an enclosure door left open, which is only worth telling someone
about while they can still act on it.
"""
providers = await self._get_providers_for_event(db, "on_ha_sensor_alert", printer_id)
if not providers:
return
variables = {
"printer": printer_name,
"sensor": sensor_name,
"state": state,
}
title, message = await self._build_message_from_template(db, "ha_sensor_alert", variables)
await self._send_to_providers(
providers,
title,
message,
db,
"ha_sensor_alert",
printer_id,
printer_name,
force_immediate=True,
variables=variables,
)
async def on_first_layer_complete(
self,
printer_id: int,

View file

@ -35,6 +35,7 @@ from backend.app.services.bambu_ftp import (
)
from backend.app.services.bambu_mqtt import HMS_MQTT_VERIFY_FAILED
from backend.app.services.filament_deficit import compute_deficit_for_queue_item
from backend.app.services.ha_sensor_manager import ha_sensor_manager
from backend.app.services.notification_service import notification_service
from backend.app.services.printer_manager import (
printer_manager,
@ -700,6 +701,31 @@ class PrintScheduler:
if inflight_pid is not None:
busy_printers.add(inflight_pid)
# Printers held by a Home Assistant sensor interlock (#1148) — an
# enclosure door left open, say. The fixed-printer branch turns
# this into a waiting_reason the user can act on; the model-based
# branch hides these printers from the matcher so an "Any <model>"
# job runs on a sibling instead of queueing behind the held one.
#
# Deliberately NOT merged into busy_printers, even though that set
# already means "unavailable this pass". _check_auto_drying reads
# it as "is currently printing" and would put an idle-but-held
# printer down the mid-print drying path, which caps the drying
# temperature and skips the queue-only gating. A held printer is
# idle; it should dry exactly as it did before.
#
# Only sensors we actually read and found alerting appear here; see
# ha_sensor_manager.blocked_printers. A Home Assistant that is down
# holds nothing.
interlocked: dict[int, str] = {}
try:
interlocked = await ha_sensor_manager.blocked_printers(db)
except Exception as e:
# Never let the interlock stop the queue running. A broken
# lookup means no holds, not no dispatches.
logger.warning("Home Assistant interlock check failed: %s", e)
interlocked = {}
# Log skip reasons once per queue check (not per item)
skip_reasons: dict[str, int] = {}
@ -759,6 +785,28 @@ class PrintScheduler:
continue
if item.printer_id:
# Held by a sensor interlock (#1148). Checked before the
# busy_printers test that would otherwise swallow it
# silently — "waiting for a printer" and "waiting for you
# to shut the enclosure" need to read differently, and only
# one of them is something the user can fix.
#
# The interlock is the only thing that writes a
# waiting_reason on this branch — the model-based branch
# nulls it at the moment it assigns a printer — so any
# reason still standing once the hold lifts is stale and is
# cleared here. Doing it at dispatch instead would leave a
# shut door reading "Waiting on Enclosure Door" for as long
# as the printer stayed busy with something else.
interlock_reason = interlocked.get(item.printer_id)
reason = f"Waiting on {interlock_reason}" if interlock_reason else None
if item.waiting_reason != reason:
item.waiting_reason = reason
await db.commit()
if interlock_reason:
skip_reasons["sensor_interlock"] = skip_reasons.get("sensor_interlock", 0) + 1
continue
# Specific printer assignment (existing behavior)
if item.printer_id in busy_printers:
continue
@ -957,7 +1005,10 @@ class PrintScheduler:
match_id, match_reason = await self._find_idle_printer_for_model(
db,
candidate.target_model,
busy_printers,
# Sensor-held printers are unavailable to the
# matcher but stay out of busy_printers itself
# (#1148) — see where `interlocked` is built.
busy_printers | interlocked.keys(),
effective_types,
item.target_location,
filament_overrides=filament_overrides,

View file

@ -0,0 +1,317 @@
"""Integration tests for the printer-bound Home Assistant sensor API (#1148)."""
from unittest.mock import AsyncMock, patch
import pytest
from httpx import AsyncClient
from backend.app.services.ha_sensor_manager import SensorReading, ha_sensor_manager
DOOR = {
"name": "Enclosure Door",
"entity_id": "binary_sensor.enclosure_door",
"kind": "binary",
"device_class": "door",
"alert_state": "on",
}
TEMP = {
"name": "Enclosure Temp",
"entity_id": "sensor.enclosure_temp",
"kind": "numeric",
"device_class": "temperature",
"unit": "°C",
}
@pytest.fixture(autouse=True)
def _no_live_ha():
"""Creating or editing a sensor reads it once; keep that off the network."""
with patch.object(ha_sensor_manager, "refresh_one", AsyncMock()):
yield
@pytest.fixture(autouse=True)
def _clean_cache():
yield
ha_sensor_manager._readings.clear()
ha_sensor_manager._last_alerting.clear()
class TestCrud:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_bind_a_door_contact(self, async_client: AsyncClient, printer_factory):
printer = await printer_factory()
response = await async_client.post("/api/v1/ha-sensors/", json={**DOOR, "printer_id": printer.id})
assert response.status_code == 200
body = response.json()
assert body["entity_id"] == "binary_sensor.enclosure_door"
assert body["kind"] == "binary"
assert body["show_on_printer_card"] is True
# Display-only until the user opts in.
assert body["block_print"] is False
assert body["notify_on_alert"] is False
@pytest.mark.asyncio
@pytest.mark.integration
async def test_rejects_a_switch(self, async_client: AsyncClient, printer_factory):
"""Switches are smart plugs; this table is read-only sensors."""
printer = await printer_factory()
response = await async_client.post(
"/api/v1/ha-sensors/",
json={**DOOR, "printer_id": printer.id, "entity_id": "switch.printer_plug"},
)
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, printer_factory):
printer = await printer_factory()
response = await async_client.post(
"/api/v1/ha-sensors/",
json={**TEMP, "printer_id": printer.id, "kind": "binary"},
)
assert response.status_code == 422
@pytest.mark.asyncio
@pytest.mark.integration
async def test_rejects_an_interlock_with_nothing_to_trigger_on(self, async_client: AsyncClient, printer_factory):
"""block_print without an alert condition would never fire — that reads
as a broken setting, not as a no-op."""
printer = await printer_factory()
response = await async_client.post(
"/api/v1/ha-sensors/",
json={**DOOR, "printer_id": printer.id, "alert_state": None, "block_print": True},
)
assert response.status_code == 422
@pytest.mark.asyncio
@pytest.mark.integration
async def test_rejects_a_duplicate_binding(self, async_client: AsyncClient, printer_factory):
printer = await printer_factory()
payload = {**DOOR, "printer_id": printer.id}
await async_client.post("/api/v1/ha-sensors/", json=payload)
response = await async_client.post("/api/v1/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_unknown_printer(self, async_client: AsyncClient):
response = await async_client.post("/api/v1/ha-sensors/", json={**DOOR, "printer_id": 9999})
assert response.status_code == 404
@pytest.mark.asyncio
@pytest.mark.integration
async def test_patch_revalidates_against_the_stored_row(self, async_client: AsyncClient, printer_factory):
"""The payload carries only block_print, so the coherence rule has to be
re-run against the merged row, not against the patch alone."""
printer = await printer_factory()
created = await async_client.post(
"/api/v1/ha-sensors/",
json={**DOOR, "printer_id": printer.id, "alert_state": None},
)
sensor_id = created.json()["id"]
response = await async_client.patch(f"/api/v1/ha-sensors/{sensor_id}", json={"block_print": True})
assert response.status_code == 422
@pytest.mark.asyncio
@pytest.mark.integration
async def test_patch_accepts_a_coherent_change(self, async_client: AsyncClient, printer_factory):
printer = await printer_factory()
created = await async_client.post("/api/v1/ha-sensors/", json={**DOOR, "printer_id": printer.id})
sensor_id = created.json()["id"]
response = await async_client.patch(
f"/api/v1/ha-sensors/{sensor_id}",
json={"block_print": True, "notify_on_alert": True, "name": "Front Door"},
)
assert response.status_code == 200
assert response.json()["block_print"] is True
assert response.json()["name"] == "Front Door"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_delete_drops_the_cached_reading(self, async_client: AsyncClient, printer_factory):
"""Otherwise a later sensor reusing the id inherits this one's state."""
printer = await printer_factory()
created = await async_client.post("/api/v1/ha-sensors/", json={**DOOR, "printer_id": printer.id})
sensor_id = created.json()["id"]
ha_sensor_manager._readings[sensor_id] = SensorReading("on", None, True, True)
response = await async_client.delete(f"/api/v1/ha-sensors/{sensor_id}")
assert response.status_code == 200
assert 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, printer_factory):
printer = await printer_factory()
created = await async_client.post(
"/api/v1/ha-sensors/",
json={**TEMP, "printer_id": printer.id, "alert_above": 35},
)
sensor_id = created.json()["id"]
ha_sensor_manager._readings[sensor_id] = SensorReading("41.2", 41.2, True, True)
response = await async_client.get(f"/api/v1/ha-sensors/by-printer/{printer.id}/readings")
assert response.status_code == 200
reading = response.json()[0]
assert reading["value"] == 41.2
assert reading["alerting"] is True
assert reading["unit"] == "°C"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_unpolled_sensor_reports_unreachable_not_missing(self, async_client: AsyncClient, printer_factory):
"""Right after a restart the card should still list the sensor, greyed
out not drop it and reflow the layout."""
printer = await printer_factory()
await async_client.post("/api/v1/ha-sensors/", json={**DOOR, "printer_id": printer.id})
response = await async_client.get(f"/api/v1/ha-sensors/by-printer/{printer.id}/readings")
assert len(response.json()) == 1
assert response.json()[0]["reachable"] is False
assert response.json()[0]["alerting"] is False
@pytest.mark.asyncio
@pytest.mark.integration
async def test_hidden_sensors_stay_off_the_card(self, async_client: AsyncClient, printer_factory):
"""An interlock the user does not want cluttering the card still works."""
printer = await printer_factory()
await async_client.post(
"/api/v1/ha-sensors/",
json={**DOOR, "printer_id": printer.id, "show_on_printer_card": False},
)
response = await async_client.get(f"/api/v1/ha-sensors/by-printer/{printer.id}/readings")
assert response.json() == []
@pytest.mark.asyncio
@pytest.mark.integration
async def test_readings_follow_sort_order(self, async_client: AsyncClient, printer_factory):
printer = await printer_factory()
await async_client.post(
"/api/v1/ha-sensors/",
json={**TEMP, "printer_id": printer.id, "sort_order": 2},
)
await async_client.post(
"/api/v1/ha-sensors/",
json={**DOOR, "printer_id": printer.id, "sort_order": 1},
)
response = await async_client.get(f"/api/v1/ha-sensors/by-printer/{printer.id}/readings")
assert [r["name"] for r in response.json()] == ["Enclosure Door", "Enclosure Temp"]
@pytest.mark.asyncio
@pytest.mark.integration
async def test_other_printers_sensors_are_not_listed(self, async_client: AsyncClient, printer_factory):
one = await printer_factory()
two = await printer_factory(serial_number="OTHER123", name="Second")
await async_client.post("/api/v1/ha-sensors/", json={**DOOR, "printer_id": one.id})
response = await async_client.get(f"/api/v1/ha-sensors/by-printer/{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/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):
"""Route ordering regression: /entities must not hit /{sensor_id}."""
response = await async_client.get("/api/v1/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, printer_factory):
printer = await printer_factory()
await async_client.post("/api/v1/ha-sensors/", json={**DOOR, "printer_id": printer.id})
second = await async_client.post("/api/v1/ha-sensors/", json={**TEMP, "printer_id": printer.id})
response = await async_client.patch(
f"/api/v1/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"]
@pytest.mark.asyncio
@pytest.mark.integration
async def test_patch_to_the_same_entity_is_not_a_clash_with_itself(
self, async_client: AsyncClient, printer_factory
):
printer = await printer_factory()
created = await async_client.post("/api/v1/ha-sensors/", json={**DOOR, "printer_id": printer.id})
response = await async_client.patch(
f"/api/v1/ha-sensors/{created.json()['id']}",
json={"entity_id": DOOR["entity_id"], "name": "Front Door"},
)
assert response.status_code == 200
@pytest.mark.asyncio
@pytest.mark.integration
async def test_deleting_a_printer_takes_its_sensors(self, async_client: AsyncClient, printer_factory):
"""The relationship cascades, so no orphan row is left holding a
printer_id that no longer resolves."""
printer = await printer_factory()
await async_client.post("/api/v1/ha-sensors/", json={**DOOR, "printer_id": printer.id})
deleted = await async_client.delete(f"/api/v1/printers/{printer.id}")
assert deleted.status_code == 200
listed = await async_client.get("/api/v1/ha-sensors/")
assert listed.json() == []
class TestSaveSurvivesHomeAssistant:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_succeeds_even_if_the_first_read_blows_up(self, async_client: AsyncClient, printer_factory):
"""The row is committed before the read. Reporting a failure for work
that succeeded would send the user into a retry that 400s on the
duplicate they just created."""
printer = await printer_factory()
with patch.object(ha_sensor_manager, "refresh_one", AsyncMock(side_effect=RuntimeError("HA said no"))):
response = await async_client.post("/api/v1/ha-sensors/", json={**DOOR, "printer_id": printer.id})
assert response.status_code == 200
listed = await async_client.get(f"/api/v1/ha-sensors/?printer_id={printer.id}")
assert len(listed.json()) == 1

View file

@ -0,0 +1,281 @@
"""Unit tests for Home Assistant sensors bound to a printer (#1148, #448).
The alert rules decide three separate things the pill colour on the card, a
notification, and whether the queue holds so they are tested directly rather
than through any one of those consumers.
The recurring theme is that "we could not read it" must never be mistaken for
a reading. A door contact whose integration has dropped out reports
"unavailable", not "closed", and treating that as closed would let a print
start into an open enclosure; treating it as *open* would strand the queue.
Neither: it is not a reading at all.
"""
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from backend.app.services.ha_sensor_manager import (
HASensorManager,
SensorReading,
describe_state,
evaluate,
)
def _sensor(**overrides):
"""A sensor row as the poller sees it, without touching the DB."""
base = {
"id": 1,
"printer_id": 4,
"name": "Enclosure Door",
"entity_id": "binary_sensor.enclosure_door",
"kind": "binary",
"device_class": "door",
"unit": None,
"alert_state": "on",
"alert_above": None,
"alert_below": None,
"block_print": False,
"notify_on_alert": False,
"last_state": None,
}
base.update(overrides)
return SimpleNamespace(**base)
def _numeric(**overrides):
base = {
"entity_id": "sensor.enclosure_temp",
"kind": "numeric",
"device_class": "temperature",
"unit": "\u00b0C",
"alert_state": None,
"name": "Enclosure Temp",
}
base.update(overrides)
return _sensor(**base)
class TestEvaluateBinary:
def test_alerts_in_the_configured_state(self):
reading = evaluate(_sensor(), {"state": "on"})
assert reading == SensorReading(state="on", value=None, alerting=True, reachable=True)
def test_quiet_in_the_other_state(self):
assert evaluate(_sensor(), {"state": "off"}).alerting is False
def test_alert_state_off_inverts_the_rule(self):
"""A "fan running" contact alarms when it stops, not when it starts."""
sensor = _sensor(alert_state="off", name="Exhaust Fan")
assert evaluate(sensor, {"state": "off"}).alerting is True
assert evaluate(sensor, {"state": "on"}).alerting is False
def test_no_alert_state_never_alerts(self):
"""Display-only sensors are the default — they just show a state."""
sensor = _sensor(alert_state=None)
assert evaluate(sensor, {"state": "on"}).alerting is False
assert evaluate(sensor, {"state": "on"}).reachable is True
def test_state_is_normalised_to_lower_case(self):
"""Some integrations report "ON"; the alert rule stores "on"."""
assert evaluate(_sensor(), {"state": "ON"}).state == "on"
assert evaluate(_sensor(), {"state": "ON"}).alerting is True
class TestEvaluateNumeric:
def test_above_threshold_alerts(self):
assert evaluate(_numeric(alert_above=35), {"state": "41.2"}).alerting is True
def test_below_threshold_alerts(self):
assert evaluate(_numeric(alert_below=15), {"state": "12"}).alerting is True
def test_inside_the_band_is_quiet(self):
reading = evaluate(_numeric(alert_above=35, alert_below=15), {"state": "22.5"})
assert reading.alerting is False
assert reading.value == 22.5
def test_exactly_on_the_threshold_is_not_an_alert(self):
"""Strict comparison, so a 35 °C limit does not alarm at exactly 35."""
assert evaluate(_numeric(alert_above=35), {"state": "35"}).alerting is False
def test_a_sensor_that_stops_reporting_numbers_does_not_alert(self):
"""Reachable, but no value to compare — so no verdict either way."""
reading = evaluate(_numeric(alert_above=35), {"state": "calibrating"})
assert reading.reachable is True
assert reading.value is None
assert reading.alerting is False
class TestUnreadable:
@pytest.mark.parametrize("state", ["unavailable", "unknown", None])
def test_ha_non_states_are_not_readings(self, state):
reading = evaluate(_sensor(), {"state": state})
assert reading.reachable is False
assert reading.alerting is False
assert reading.state is None
def test_a_failed_fetch_is_not_a_reading(self):
"""fetch_states maps an entity it could not read to None."""
reading = evaluate(_sensor(), None)
assert reading == SensorReading(state=None, value=None, alerting=False, reachable=False)
class TestDescribeState:
def test_binary_uses_the_raw_state(self):
assert describe_state(_sensor(), evaluate(_sensor(), {"state": "on"})) == "on"
def test_numeric_carries_its_unit(self):
sensor = _numeric()
assert describe_state(sensor, evaluate(sensor, {"state": "41.20"})) == "41.2 °C"
def test_numeric_without_a_unit_is_bare(self):
sensor = _numeric(unit=None)
assert describe_state(sensor, evaluate(sensor, {"state": "7"})) == "7"
class TestBlockedPrinters:
"""The interlock only ever reports a positive, current finding."""
def _manager_with(self, sensors, readings):
manager = HASensorManager()
manager._readings = readings
db = AsyncMock()
db.execute.return_value = SimpleNamespace(scalars=lambda: SimpleNamespace(all=lambda: sensors))
return manager, db
@pytest.mark.asyncio
async def test_reports_an_alerting_blocking_sensor(self):
sensor = _sensor(block_print=True)
manager, db = self._manager_with([sensor], {1: SensorReading("on", None, True, True)})
assert await manager.blocked_printers(db) == {4: "Enclosure Door"}
@pytest.mark.asyncio
async def test_silent_when_not_alerting(self):
sensor = _sensor(block_print=True)
manager, db = self._manager_with([sensor], {1: SensorReading("off", None, False, True)})
assert await manager.blocked_printers(db) == {}
@pytest.mark.asyncio
async def test_silent_when_home_assistant_is_unreachable(self):
"""The queue must keep running when HA is down, not seize up."""
sensor = _sensor(block_print=True)
manager, db = self._manager_with([sensor], {1: SensorReading(None, None, False, False)})
assert await manager.blocked_printers(db) == {}
@pytest.mark.asyncio
async def test_silent_before_the_first_poll(self):
"""A cold cache is not evidence the door is open."""
sensor = _sensor(block_print=True)
manager, db = self._manager_with([sensor], {})
assert await manager.blocked_printers(db) == {}
@pytest.mark.asyncio
async def test_names_every_blocking_sensor_on_a_printer(self):
sensors = [
_sensor(id=1, block_print=True, name="Front Door"),
_sensor(id=2, block_print=True, name="Side Panel"),
]
manager, db = self._manager_with(
sensors,
{
1: SensorReading("on", None, True, True),
2: SensorReading("on", None, True, True),
},
)
assert await manager.blocked_printers(db) == {4: "Front Door, Side Panel"}
class TestNotificationEdge:
"""Alerts fire on the transition into the alert state, not while it lasts."""
async def _apply(self, manager, sensor, states, notify):
db = AsyncMock()
db.get.return_value = SimpleNamespace(name="X1C-1")
with patch("backend.app.services.notification_service.notification_service", notify):
await manager._apply(db, [sensor], states)
@pytest.mark.asyncio
async def test_fires_once_on_the_way_in(self):
manager = HASensorManager()
sensor = _sensor(notify_on_alert=True)
notify = AsyncMock()
# First poll seeds the cache; a door already open at startup has not
# just been opened.
await self._apply(manager, sensor, {sensor.entity_id: {"state": "off"}}, notify)
assert notify.on_ha_sensor_alert.await_count == 0
await self._apply(manager, sensor, {sensor.entity_id: {"state": "on"}}, notify)
assert notify.on_ha_sensor_alert.await_count == 1
# Still open on the next pass — no second alert.
await self._apply(manager, sensor, {sensor.entity_id: {"state": "on"}}, notify)
assert notify.on_ha_sensor_alert.await_count == 1
@pytest.mark.asyncio
async def test_silent_on_the_first_poll_after_a_restart(self):
"""Cold cache. Re-announcing every pre-existing alert on every restart
is how users learn to ignore the alert."""
manager = HASensorManager()
sensor = _sensor(notify_on_alert=True)
notify = AsyncMock()
await self._apply(manager, sensor, {sensor.entity_id: {"state": "on"}}, notify)
assert notify.on_ha_sensor_alert.await_count == 0
assert manager.get_reading(sensor.id).alerting is True
@pytest.mark.asyncio
async def test_silent_when_the_sensor_opts_out(self):
manager = HASensorManager()
sensor = _sensor(notify_on_alert=False)
notify = AsyncMock()
await self._apply(manager, sensor, {sensor.entity_id: {"state": "off"}}, notify)
await self._apply(manager, sensor, {sensor.entity_id: {"state": "on"}}, notify)
assert notify.on_ha_sensor_alert.await_count == 0
@pytest.mark.asyncio
async def test_re_arms_after_the_alert_clears(self):
manager = HASensorManager()
sensor = _sensor(notify_on_alert=True)
notify = AsyncMock()
for state in ("off", "on", "off", "on"):
await self._apply(manager, sensor, {sensor.entity_id: {"state": state}}, notify)
assert notify.on_ha_sensor_alert.await_count == 2
@pytest.mark.asyncio
async def test_a_dropout_does_not_count_as_the_alert_clearing(self):
"""on -> unavailable -> on is one continuous alert, not two.
Without this, a flaky Zigbee contact would notify on every reconnect.
"""
manager = HASensorManager()
sensor = _sensor(notify_on_alert=True)
notify = AsyncMock()
await self._apply(manager, sensor, {sensor.entity_id: {"state": "off"}}, notify)
await self._apply(manager, sensor, {sensor.entity_id: {"state": "on"}}, notify)
await self._apply(manager, sensor, {sensor.entity_id: None}, notify)
await self._apply(manager, sensor, {sensor.entity_id: {"state": "on"}}, notify)
assert notify.on_ha_sensor_alert.await_count == 1

View file

@ -0,0 +1,306 @@
"""The Home Assistant sensor interlock, seen from the scheduler (#1148).
The feature exists because the reporter wanted to know his enclosure was shut
before starting a print remotely. Displaying the door state only helps if he
looks; the interlock is what makes it act on its own.
It is a *hold*, never a failure: the item stays pending and dispatches by
itself once the door closes. And it holds only on a positive, freshly read
finding a Home Assistant that is unreachable holds nothing, because a queue
that stops whenever an unrelated service goes down is worse than the problem.
"""
from contextlib import ExitStack
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from sqlalchemy import select
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.library import LibraryFile
from backend.app.models.print_queue import PrintQueueItem
from backend.app.models.printer import Printer
from backend.app.services.print_scheduler import PrintScheduler
@pytest.fixture
async def queue_db():
"""Two X1Cs, so a model-based job has somewhere else to go."""
engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
session_maker = async_sessionmaker(engine, expire_on_commit=False)
async with session_maker() as db:
db.add_all(
[
Printer(
id=1,
name="X1C-1",
serial_number="X1C0001",
ip_address="10.0.0.1",
access_code="x",
model="X1C",
is_active=True,
),
Printer(
id=2,
name="X1C-2",
serial_number="X1C0002",
ip_address="10.0.0.2",
access_code="x",
model="X1C",
is_active=True,
),
]
)
await db.commit()
try:
yield SimpleNamespace(session_maker=session_maker)
finally:
await engine.dispose()
async def _add_item(ctx, *, printer_id=None, target_model=None):
async with ctx.session_maker() as db:
lib = LibraryFile(
filename="job.gcode.3mf",
file_path="/library/job.gcode.3mf",
file_size=10,
file_type="gcode.3mf",
file_metadata={"sliced_for_model": "X1C"},
)
db.add(lib)
await db.flush()
item = PrintQueueItem(
status="pending",
position=1,
printer_id=printer_id,
target_model=target_model,
library_file_id=lib.id,
)
db.add(item)
await db.commit()
return item.id
async def _run(ctx, scheduler, blocked, launched, finder=None, idle=True, drying=None):
patches = [
patch("backend.app.services.print_scheduler.async_session", ctx.session_maker),
patch("backend.app.core.database.async_session", ctx.session_maker),
patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock(return_value=None)),
patch(
"backend.app.services.print_scheduler.ha_sensor_manager.blocked_printers",
AsyncMock(return_value=blocked),
),
patch(
"backend.app.services.notification_service.notification_service.on_queue_job_waiting",
AsyncMock(),
),
patch(
"backend.app.services.notification_service.notification_service.on_queue_job_assigned",
AsyncMock(),
),
patch.object(scheduler, "_is_printer_idle", MagicMock(return_value=idle)),
patch.object(scheduler, "_check_auto_drying", drying or AsyncMock()),
patch.object(scheduler, "_ensure_ams_mapping", AsyncMock(return_value=None)),
patch.object(scheduler, "_block_on_filament_deficit", AsyncMock(return_value=False)),
patch.object(scheduler, "_launch_uploads", launched),
]
if finder is not None:
patches.append(patch.object(scheduler, "_find_idle_printer_for_model", finder))
with ExitStack() as stack:
for p in patches:
stack.enter_context(p)
return await scheduler.check_queue()
async def _get_item(ctx, item_id):
async with ctx.session_maker() as db:
return (await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))).scalar_one()
class TestFixedPrinter:
@pytest.mark.asyncio
async def test_holds_the_item_and_says_why(self, queue_db):
item_id = await _add_item(queue_db, printer_id=1)
launched = MagicMock()
await _run(queue_db, PrintScheduler(), {1: "Enclosure Door"}, launched)
launched.assert_not_called()
item = await _get_item(queue_db, item_id)
assert item.status == "pending"
assert item.waiting_reason == "Waiting on Enclosure Door"
@pytest.mark.asyncio
async def test_holding_is_not_failing(self, queue_db):
"""The door being open is a thing the user fixes in five seconds. The
job must be there waiting when they do, not failed."""
item_id = await _add_item(queue_db, printer_id=1)
await _run(queue_db, PrintScheduler(), {1: "Enclosure Door"}, MagicMock())
item = await _get_item(queue_db, item_id)
assert item.status == "pending"
assert item.error_message is None
assert item.completed_at is None
@pytest.mark.asyncio
async def test_dispatches_once_the_hold_clears(self, queue_db):
item_id = await _add_item(queue_db, printer_id=1)
scheduler = PrintScheduler()
await _run(queue_db, scheduler, {1: "Enclosure Door"}, MagicMock())
launched = MagicMock()
await _run(queue_db, scheduler, {}, launched)
launched.assert_called_once()
assert launched.call_args[0][0] == [item_id]
@pytest.mark.asyncio
async def test_the_stale_reason_is_cleared_on_dispatch(self, queue_db):
"""Otherwise the queue shows "Waiting on Enclosure Door" against a job
that is already printing."""
item_id = await _add_item(queue_db, printer_id=1)
scheduler = PrintScheduler()
await _run(queue_db, scheduler, {1: "Enclosure Door"}, MagicMock())
await _run(queue_db, scheduler, {}, MagicMock())
assert (await _get_item(queue_db, item_id)).waiting_reason is None
@pytest.mark.asyncio
async def test_the_reason_clears_even_when_the_printer_is_still_busy(self, queue_db):
"""You shut the door, but the printer is midway through something else.
The hold has lifted and the queue must say so. Clearing the reason only
at dispatch would leave a shut door reading "Waiting on Enclosure Door"
for the rest of a ten-hour print.
"""
item_id = await _add_item(queue_db, printer_id=1)
scheduler = PrintScheduler()
await _run(queue_db, scheduler, {1: "Enclosure Door"}, MagicMock())
launched = MagicMock()
await _run(queue_db, scheduler, {}, launched, idle=False)
launched.assert_not_called()
assert (await _get_item(queue_db, item_id)).waiting_reason is None
@pytest.mark.asyncio
async def test_another_printers_sensor_does_not_hold_this_one(self, queue_db):
item_id = await _add_item(queue_db, printer_id=1)
launched = MagicMock()
await _run(queue_db, PrintScheduler(), {2: "Enclosure Door"}, launched)
launched.assert_called_once()
assert launched.call_args[0][0] == [item_id]
@pytest.mark.asyncio
async def test_nothing_blocked_dispatches_as_before(self, queue_db):
item_id = await _add_item(queue_db, printer_id=1)
launched = MagicMock()
await _run(queue_db, PrintScheduler(), {}, launched)
launched.assert_called_once()
assert launched.call_args[0][0] == [item_id]
@pytest.mark.asyncio
async def test_a_held_printer_is_not_reported_as_busy(self, queue_db):
"""A held printer is idle, not printing, and the rest of the scheduler
must keep seeing it that way.
busy_printers looks like the obvious place to put a hold, but
_check_auto_drying reads that set as "is currently printing" and would
route an idle-but-held printer down the mid-print drying path capped
temperature, and past the queue-only gating. Auto-drying must see this
printer exactly as it did before the interlock existed.
"""
await _add_item(queue_db, printer_id=1)
scheduler = PrintScheduler()
drying = AsyncMock()
await _run(queue_db, scheduler, {1: "Enclosure Door"}, MagicMock(), drying=drying)
busy_printers = drying.await_args[0][2]
assert 1 not in busy_printers
@pytest.mark.asyncio
async def test_a_failing_interlock_lookup_never_stops_the_queue(self, queue_db):
"""If the check itself breaks, the answer is "no holds", not "no prints"."""
item_id = await _add_item(queue_db, printer_id=1)
launched = MagicMock()
broken = AsyncMock(side_effect=RuntimeError("HA sensor table is on fire"))
with patch(
"backend.app.services.print_scheduler.ha_sensor_manager.blocked_printers",
broken,
):
await _run(queue_db, PrintScheduler(), {}, launched)
launched.assert_called_once()
assert launched.call_args[0][0] == [item_id]
class TestModelBased:
def _finder(self):
"""Stand-in matcher that respects busy_printers, as the real one does.
That is the whole contract under test here: the interlock folds held
printers into busy_printers, so a matcher that honours the set honours
the interlock without knowing it exists.
"""
async def finder(db, model, busy, *args, **kwargs):
for printer_id in (1, 2):
if printer_id not in busy:
return printer_id, None
return None, "All printers busy"
return finder
@pytest.mark.asyncio
async def test_an_interlocked_printer_is_passed_over_for_its_sibling(self, queue_db):
"""The whole reason the hold is folded into busy_printers: an "Any X1C"
job should run on the printer whose door is shut, not queue behind the
one whose door is open."""
item_id = await _add_item(queue_db, target_model="X1C")
launched = MagicMock()
await _run(
queue_db,
PrintScheduler(),
{1: "Enclosure Door"},
launched,
finder=self._finder(),
)
launched.assert_called_once()
assert launched.call_args[0][0] == [item_id]
assert (await _get_item(queue_db, item_id)).printer_id == 2
@pytest.mark.asyncio
async def test_every_printer_held_leaves_the_job_waiting(self, queue_db):
item_id = await _add_item(queue_db, target_model="X1C")
launched = MagicMock()
await _run(
queue_db,
PrintScheduler(),
{1: "Enclosure Door", 2: "Enclosure Door"},
launched,
finder=self._finder(),
)
launched.assert_not_called()
item = await _get_item(queue_db, item_id)
assert item.status == "pending"
assert item.printer_id is None

View file

@ -0,0 +1,91 @@
/**
* The Home Assistant sensor modal (#1148, #448).
*
* Focused on the unconfigured-Home-Assistant path, which is the state a first
* time user is actually in: the entity picker can only ever come back empty
* there, and an empty list reads as "I have no sensors" rather than as "you
* have not connected Home Assistant yet".
*/
import { screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { HASensorModal } from '../../components/HASensorModal';
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(), getBindableHAEntities: vi.fn() },
};
});
const getSettings = vi.mocked(api.getSettings);
const getEntities = vi.mocked(api.getBindableHAEntities);
const PRINTERS = [{ id: 4, name: 'X1C-1' }] as never;
function settings(overrides = {}) {
return {
ha_enabled: true,
ha_url: 'http://homeassistant.local:8123',
ha_token: 'token',
...overrides,
} as never;
}
describe('HASensorModal', () => {
beforeEach(() => {
getSettings.mockReset();
getEntities.mockReset();
getEntities.mockResolvedValue([]);
});
it('warns when Home Assistant is not configured at all', async () => {
getSettings.mockResolvedValue(settings({ ha_enabled: false, ha_url: '', ha_token: '' }));
render(<HASensorModal printers={PRINTERS} 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(<HASensorModal printers={PRINTERS} 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(<HASensorModal printers={PRINTERS} 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(<HASensorModal printers={PRINTERS} 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(<HASensorModal printers={PRINTERS} onClose={() => {}} />);
await waitFor(() => expect(getEntities).toHaveBeenCalled());
expect(screen.queryByText(/Home Assistant is not configured/)).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,161 @@
/**
* The Home Assistant sensor row on the printer card (#1148, #448).
*
* The reporter's whole ask is a glance-and-know row: "is the enclosure shut?"
* So the assertions are about what the pill actually says. "on" is not an
* answer to that question "Open" is, and only Home Assistant's device_class
* tells us which word to use.
*/
import { screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { PrinterHASensorRow } from '../../components/PrinterHASensorRow';
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, getHASensorReadings: vi.fn() } };
});
const getReadings = vi.mocked(api.getHASensorReadings);
function reading(overrides = {}) {
return {
id: 1,
name: 'Enclosure Door',
entity_id: 'binary_sensor.enclosure_door',
kind: 'binary' as const,
device_class: 'door',
unit: null,
state: 'off',
value: null,
alerting: false,
block_print: false,
reachable: true,
last_changed: null,
...overrides,
};
}
describe('PrinterHASensorRow', () => {
beforeEach(() => {
getReadings.mockReset();
});
it('renders nothing when the printer has no sensors', async () => {
getReadings.mockResolvedValue([]);
render(<PrinterHASensorRow printerId={4} />);
await waitFor(() => expect(getReadings).toHaveBeenCalledWith(4));
// No label, no divider, no empty-state placeholder — the row must not
// reserve space on every card that has no sensors configured.
expect(screen.queryByText('Sensors')).not.toBeInTheDocument();
});
it('names a door state by its device class, not by on/off', async () => {
getReadings.mockResolvedValue([reading({ state: 'off' })]);
render(<PrinterHASensorRow printerId={4} />);
expect(await screen.findByText('Closed')).toBeInTheDocument();
expect(screen.getByText('Enclosure Door')).toBeInTheDocument();
});
it('says Open for the same sensor in the other state', async () => {
getReadings.mockResolvedValue([reading({ state: 'on', alerting: true })]);
render(<PrinterHASensorRow printerId={4} />);
expect(await screen.findByText('Open')).toBeInTheDocument();
});
it('falls back to on/off for a class it has no wording for', async () => {
getReadings.mockResolvedValue([reading({ device_class: null, state: 'on' })]);
render(<PrinterHASensorRow printerId={4} />);
expect(await screen.findByText('On')).toBeInTheDocument();
});
it('shows a numeric reading with its unit', async () => {
getReadings.mockResolvedValue([
reading({
kind: 'numeric',
device_class: 'temperature',
entity_id: 'sensor.enclosure_temp',
name: 'Enclosure Temp',
unit: '°C',
state: '41.2',
value: 41.2,
}),
]);
render(<PrinterHASensorRow printerId={4} />);
expect(await screen.findByText('41.2 °C')).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.
getReadings.mockResolvedValue([reading({ state: null, reachable: false })]);
render(<PrinterHASensorRow printerId={4} />);
expect(await screen.findByText('Unavailable')).toBeInTheDocument();
expect(screen.queryByText('Closed')).not.toBeInTheDocument();
});
it('marks an alerting sensor for the eye', async () => {
getReadings.mockResolvedValue([reading({ state: 'on', alerting: true })]);
render(<PrinterHASensorRow printerId={4} />);
const pill = (await screen.findByText('Open')).closest('span[title]');
expect(pill?.className).toContain('red');
});
it('does not colour a quiet sensor as an alert', async () => {
getReadings.mockResolvedValue([reading({ state: 'on', alerting: false })]);
render(<PrinterHASensorRow printerId={4} />);
const pill = (await screen.findByText('Open')).closest('span[title]');
expect(pill?.className).not.toContain('red');
});
it('says so in the tooltip when a sensor holds prints', async () => {
getReadings.mockResolvedValue([reading({ block_print: true })]);
render(<PrinterHASensorRow printerId={4} />);
await screen.findByText('Closed');
expect(
screen.getByTitle('binary_sensor.enclosure_door — holds prints while alerting')
).toBeInTheDocument();
});
it('lists every sensor bound to the printer', async () => {
getReadings.mockResolvedValue([
reading(),
reading({
id: 2,
kind: 'numeric',
name: 'Enclosure Temp',
entity_id: 'sensor.enclosure_temp',
device_class: 'temperature',
unit: '°C',
state: '22',
value: 22,
}),
]);
render(<PrinterHASensorRow printerId={4} />);
expect(await screen.findByText('Enclosure Door')).toBeInTheDocument();
expect(screen.getByText('Enclosure Temp')).toBeInTheDocument();
});
});

View file

@ -2167,6 +2167,75 @@ export interface HATestConnectionResult {
error: string | null;
}
// A Home Assistant entity bound to a printer for display on its card (#1148, #448).
// Read-only: unlike a SmartPlug there is nothing here to switch.
export interface PrinterHASensor {
id: number;
printer_id: number;
name: string;
entity_id: string;
kind: 'binary' | 'numeric';
device_class: string | null; // HA's own class: "door", "temperature", ...
unit: string | null; // numeric sensors only
// What counts as needing attention. Binary sensors use alert_state, numeric
// ones the thresholds; all null means the sensor is display-only.
alert_state: 'on' | 'off' | null;
alert_above: number | null;
alert_below: number | null;
block_print: boolean; // hold the printer's queue while alerting
notify_on_alert: boolean;
show_on_printer_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 PrinterHASensorReading {
id: number;
name: string;
entity_id: string;
kind: 'binary' | 'numeric';
device_class: string | null;
unit: string | null;
state: string | null; // null when unreadable or not yet polled
value: number | null; // numeric sensors only
alerting: boolean;
block_print: boolean;
reachable: boolean;
last_changed: string | null;
}
export interface PrinterHASensorCreate {
printer_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;
block_print?: boolean;
notify_on_alert?: boolean;
show_on_printer_card?: boolean;
sort_order?: number;
}
export type PrinterHASensorUpdate = Partial<Omit<PrinterHASensorCreate, 'printer_id'>>;
// An entity offered by the binding picker.
export interface HADisplayEntity {
entity_id: string;
friendly_name: string;
state: string | null;
domain: string; // "binary_sensor" | "sensor"
device_class: string | null;
unit_of_measurement: string | null;
}
export interface SmartPlugEnergy {
power: number | null; // Current watts
voltage: number | null; // Volts
@ -2615,6 +2684,7 @@ export interface NotificationProvider {
on_plate_clear_required: boolean;
// Bed cooled
on_bed_cooled: boolean;
on_ha_sensor_alert: boolean;
// First layer complete
on_first_layer_complete: boolean;
// Inventory stock alerts
@ -2675,6 +2745,7 @@ export interface NotificationProviderCreate {
on_plate_clear_required?: boolean;
// Bed cooled
on_bed_cooled?: boolean;
on_ha_sensor_alert?: boolean;
// First layer complete
on_first_layer_complete?: boolean;
// Inventory stock alerts
@ -2728,6 +2799,7 @@ export interface NotificationProviderUpdate {
on_plate_clear_required?: boolean;
// Bed cooled
on_bed_cooled?: boolean;
on_ha_sensor_alert?: boolean;
// First layer complete
on_first_layer_complete?: boolean;
// Inventory stock alerts
@ -5195,6 +5267,22 @@ export const api = {
getHASensorEntities: () =>
request<HASensorEntity[]>('/smart-plugs/ha/sensors'),
// Home Assistant sensors bound to a printer (#1148, #448)
getHASensors: (printerId?: number) =>
request<PrinterHASensor[]>(`/ha-sensors/${printerId ? `?printer_id=${printerId}` : ''}`),
getHASensorReadings: (printerId: number) =>
request<PrinterHASensorReading[]>(`/ha-sensors/by-printer/${printerId}/readings`),
getBindableHAEntities: (search?: string) => {
const params = search ? `?search=${encodeURIComponent(search)}` : '';
return request<HADisplayEntity[]>(`/ha-sensors/entities${params}`);
},
createHASensor: (data: PrinterHASensorCreate) =>
request<PrinterHASensor>('/ha-sensors/', { method: 'POST', body: JSON.stringify(data) }),
updateHASensor: (id: number, data: PrinterHASensorUpdate) =>
request<PrinterHASensor>(`/ha-sensors/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
deleteHASensor: (id: number) =>
request<{ message: string }>(`/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', {

View file

@ -45,6 +45,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
const [onStockBreakAlert, setOnStockBreakAlert] = useState(provider?.on_stock_break_alert ?? false);
const [onPlateClearRequired, setOnPlateClearRequired] = useState(provider?.on_plate_clear_required ?? false);
const [onBedCooled, setOnBedCooled] = useState(provider?.on_bed_cooled ?? false);
const [onHaSensorAlert, setOnHaSensorAlert] = useState(provider?.on_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
@ -200,6 +201,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
on_stock_break_alert: onStockBreakAlert,
on_plate_clear_required: onPlateClearRequired,
on_bed_cooled: onBedCooled,
on_ha_sensor_alert: onHaSensorAlert,
on_first_layer_complete: onFirstLayerComplete,
};
@ -635,6 +637,13 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
<span className="text-sm text-white">{t('notifications.offline')}</span>
<Toggle checked={onPrinterOffline} onChange={setOnPrinterOffline} />
</div>
<div className="flex items-center justify-between col-span-2">
<div>
<span className="text-sm text-white">{t('notifications.haSensorAlert')}</span>
<span className="text-xs text-bambu-gray ml-1">{t('notifications.haSensorAlertDescription')}</span>
</div>
<Toggle checked={onHaSensorAlert} onChange={setOnHaSensorAlert} />
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-white">{t('notifications.error')}</span>
<Toggle checked={onPrinterError} onChange={setOnPrinterError} />
@ -688,6 +697,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
if (onFirstLayerComplete) enabledEvents.push({ key: 'on_first_layer_complete', label: t('notifications.firstLayerCompleteLabel') });
if (onPrinterOffline) enabledEvents.push({ key: 'on_printer_offline', label: t('notifications.offline') });
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 (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') });

View file

@ -0,0 +1,413 @@
import { useEffect, useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Gauge, Loader2, Save, Search, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { api } from '../api/client';
import type { HADisplayEntity, Printer, PrinterHASensor } from '../api/client';
import { Button } from './Button';
import { useToast } from '../contexts/ToastContext';
/**
* Bind a Home Assistant entity to a printer, or edit an existing binding
* (#1148, #448).
*
* The entity picker is the load-bearing part: kind, device_class and unit all
* come from the entity rather than from the user, because getting any of them
* wrong is a validation error from the backend that nobody could act on.
*/
interface Props {
sensor?: PrinterHASensor | null;
printers: Printer[];
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();
const { showToast } = useToast();
const isEditing = !!sensor;
const [printerId, setPrinterId] = useState<number | ''>(sensor?.printer_id ?? printers[0]?.id ?? '');
const [entityId, setEntityId] = useState(sensor?.entity_id ?? '');
const [kind, setKind] = useState<'binary' | 'numeric'>(sensor?.kind ?? 'binary');
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 ?? '');
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 [showOnCard, setShowOnCard] = useState(sensor?.show_on_printer_card ?? true);
const [notifyOnAlert, setNotifyOnAlert] = useState(sensor?.notify_on_alert ?? false);
const [blockPrint, setBlockPrint] = useState(sensor?.block_print ?? false);
const [search, setSearch] = useState('');
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
// Same gate and the same wording as AddSmartPlugModal: without a configured
// Home Assistant the picker can only return an error, so say why up front
// instead of showing an empty list.
const { data: settings } = useQuery({
queryKey: ['settings'],
queryFn: api.getSettings,
});
const haConfigured = !!(settings?.ha_enabled && settings?.ha_url && settings?.ha_token);
const { data: entities, isLoading: entitiesLoading, error: entitiesError } = useQuery({
queryKey: ['bindableHAEntities'],
queryFn: () => api.getBindableHAEntities(),
enabled: haConfigured,
});
const filtered = useMemo(() => {
const needle = search.trim().toLowerCase();
const all = entities ?? [];
if (!needle) return all;
return all.filter(
(e) => e.entity_id.toLowerCase().includes(needle) || e.friendly_name.toLowerCase().includes(needle)
);
}, [entities, search]);
const selectEntity = (entity: HADisplayEntity) => {
setEntityId(entity.entity_id);
setDeviceClass(entity.device_class);
setUnit(entity.unit_of_measurement);
const nextKind = entity.domain === 'binary_sensor' ? 'binary' : 'numeric';
setKind(nextKind);
// Switching kind strands the other kind's alert fields, and the backend
// rejects a numeric sensor that still carries an alert_state.
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.
if (!name.trim()) setName(entity.friendly_name.slice(0, 100));
};
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['haSensors'] });
queryClient.invalidateQueries({ queryKey: ['haSensorReadings'] });
};
const saveMutation = useMutation({
mutationFn: () => {
const payload = {
name: name.trim(),
entity_id: entityId,
kind,
device_class: deviceClass,
unit,
alert_state: kind === 'binary' && alertState ? alertState : null,
alert_above: kind === 'numeric' && alertAbove !== '' ? Number(alertAbove) : null,
alert_below: kind === 'numeric' && alertBelow !== '' ? Number(alertBelow) : null,
block_print: blockPrint,
notify_on_alert: notifyOnAlert,
show_on_printer_card: showOnCard,
};
return isEditing
? api.updateHASensor(sensor.id, payload)
: api.createHASensor({ ...payload, printer_id: Number(printerId) });
},
onSuccess: () => {
invalidate();
showToast(isEditing ? t('haSensors.toast.updated') : t('haSensors.toast.created'), 'success');
onClose();
},
onError: (err: Error) => setError(err.message),
});
const deleteMutation = useMutation({
mutationFn: () => api.deleteHASensor(sensor!.id),
onSuccess: () => {
invalidate();
showToast(t('haSensors.toast.deleted'), 'success');
onClose();
},
onError: (err: Error) => 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 (printerId === '') return setError(t('haSensors.error.printerRequired'));
// Mirrors the backend rule, so the user is told before the round trip
// rather than by a 422.
if ((blockPrint || notifyOnAlert) && !hasAlertCondition) {
return setError(t('haSensors.error.alertRequired'));
}
saveMutation.mutate();
};
const alertLabels = ALERT_LABEL_KEYS[deviceClass ?? ''];
const stateLabel = (which: 'on' | 'off') => {
const key = alertLabels?.[which] ?? which;
return t(`haSensors.states.${key}`, { defaultValue: key });
};
const isPending = saveMutation.isPending || deleteMutation.isPending;
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">
<Gauge className="w-5 h-5" />
</div>
<h2 className="text-lg font-semibold text-white">
{isEditing ? t('haSensors.editTitle') : t('haSensors.addTitle')}
</h2>
</div>
<button onClick={onClose} className="text-bambu-gray hover:text-white transition-colors">
<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>
)}
{!isEditing && (
<div>
<label className="block text-sm text-bambu-gray mb-1">{t('haSensors.printer')}</label>
<select
value={printerId}
onChange={(e) => setPrinterId(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"
>
{printers.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</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'
}`}
>
{!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">{t('haSensors.name')}</label>
<input
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>
<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>
) : (
<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('haSensors.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>
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={blockPrint}
onChange={(e) => setBlockPrint(e.target.checked)}
disabled={!hasAlertCondition}
className="w-4 h-4 mt-0.5"
/>
<span>
<span className={`block text-sm ${hasAlertCondition ? 'text-white' : 'text-bambu-gray'}`}>
{t('haSensors.blockPrint')}
</span>
<span className="block text-xs text-bambu-gray">{t('haSensors.blockPrintHint')}</span>
</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>
{/* An unconfigured Home Assistant leaves nothing to bind to.
Editing an existing sensor still saves its alert rule and
card visibility are Bambuddy's own settings and do not need
Home Assistant to be reachable to change. */}
<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>
);
}

View file

@ -144,6 +144,9 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
{provider.on_ai_failure_detection && (
<span className="px-2 py-0.5 bg-fuchsia-100 dark:bg-fuchsia-500/20 text-fuchsia-700 dark:text-fuchsia-300 text-xs rounded">{t('notifications.aiFailureDetection')}</span>
)}
{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_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>
)}
@ -394,6 +397,17 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">{t('notifications.haSensorAlert')}</p>
<p className="text-xs text-bambu-gray">{t('notifications.haSensorAlertDescription')}</p>
</div>
<Toggle
checked={provider.on_ha_sensor_alert ?? false}
onChange={(checked) => updateMutation.mutate({ on_ha_sensor_alert: checked })}
/>
</div>
<div className="flex items-center justify-between">
<p className="text-sm text-white">{t('notifications.lowFilamentLabel')}</p>
<Toggle

View file

@ -0,0 +1,142 @@
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 { useTranslation } from 'react-i18next';
import { api } from '../api/client';
import type { PrinterHASensorReading } from '../api/client';
/**
* The Home Assistant sensors bound to a printer, on its card (#1148, #448).
*
* Read-only by design these are contacts and thermometers, not switches, so
* nothing here is clickable. The sibling "HA:" row above handles the entities
* 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;
}
export function PrinterHASensorRow({ printerId }: Props) {
const { t } = useTranslation();
const { data: readings } = useQuery({
queryKey: ['haSensorReadings', printerId],
queryFn: () => api.getHASensorReadings(printerId),
// Served from the backend poller's cache, so this costs a local request
// and never a Home Assistant round trip. Matched to the poller's own
// cadence — refetching faster would only re-read the same reading.
refetchInterval: 15000,
});
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 });
};
return (
<div className="flex items-center gap-2 mt-2">
<Gauge className="w-[var(--pc-i35,0.875rem)] h-[var(--pc-i35,0.875rem)] text-blue-600 dark:text-blue-400 flex-shrink-0" />
<span className="text-xs text-bambu-gray">{t('haSensors.label')}</span>
<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 unreachable = !reading.reachable || reading.state === null;
return (
<span
key={reading.id}
title={
reading.block_print
? t('haSensors.blocksPrints', { entity: reading.entity_id })
: reading.entity_id
}
className={`px-2 py-0.5 text-xs rounded flex items-center gap-1 ${
unreachable
? 'bg-bambu-dark-tertiary/50 text-bambu-gray'
: reading.alerting
? 'bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-400'
: 'bg-bambu-dark-tertiary text-bambu-gray'
}`}
>
<Icon className="w-[var(--pc-i25,0.625rem)] h-[var(--pc-i25,0.625rem)]" />
<span>{reading.name}</span>
<span className="font-medium">{describe(reading)}</span>
</span>
);
})}
</div>
</div>
);
}

View file

@ -5412,6 +5412,61 @@ export default {
},
// Smart Plugs
haSensors: {
label: 'Sensoren',
unavailable: 'Nicht verfügbar',
blocksPrints: '{{entity}} — hält Drucke zurück, solange der Alarm aktiv ist',
states: {
open: 'Offen',
closed: 'Geschlossen',
unlocked: 'Entriegelt',
locked: 'Verriegelt',
detected: 'Erkannt',
clear: 'Frei',
wet: 'Nass',
dry: 'Trocken',
problem: 'Problem',
ok: 'OK',
running: 'Läuft',
stopped: 'Gestoppt',
on: 'An',
off: 'Aus',
},
sectionTitle: 'Home-Assistant-Sensoren',
add: 'Sensor hinzufügen',
addTitle: 'Home-Assistant-Sensor hinzufügen',
editTitle: 'Home-Assistant-Sensor bearbeiten',
empty: 'Noch keine Sensoren. Verknüpfe einen Türkontakt oder ein Thermometer aus Home Assistant, um es auf der Druckerkarte anzuzeigen.',
unknownPrinter: 'Unbekannter Drucker',
badgeBlocks: 'Hält Drucke zurück',
badgeNotifies: 'Benachrichtigt',
badgeHidden: 'Auf Karte ausgeblendet',
printer: 'Drucker',
entity: 'Entität',
searchPlaceholder: 'Entitäten suchen ...',
noEntities: 'Keine passenden Entitäten',
name: 'Anzeigename',
alertWhen: 'Alarm wenn',
alertNever: 'Nie — nur anzeigen',
alertAbove: 'Über',
alertBelow: 'Unter',
alertHint: 'Der Alarmzustand hebt den Sensor auf der Druckerkarte hervor und aktiviert die Optionen unten.',
showOnCard: 'Auf Druckerkarte anzeigen',
notifyOnAlert: 'Benachrichtigen, sobald der Alarm ausgelöst wird',
blockPrint: 'Wartende Drucke zurückhalten, solange der Alarm aktiv ist',
blockPrintHint: 'Aufträge bleiben in der Warteschlange und starten von selbst, sobald der Sensor wieder frei ist. Wird ignoriert, solange Home Assistant nicht erreichbar ist.',
toast: {
created: 'Sensor hinzugefügt',
updated: 'Sensor gespeichert',
deleted: 'Sensor entfernt',
},
error: {
pickEntity: 'Wähle eine Home-Assistant-Entität',
nameRequired: 'Gib einen Anzeigenamen ein',
printerRequired: 'Wähle einen Drucker',
alertRequired: 'Lege zuerst eine Alarmbedingung fest',
},
},
smartPlugs: {
offline: 'Offline',
admin: 'Admin',
@ -5724,6 +5779,8 @@ export default {
notificationEvents: 'Benachrichtigungsereignisse',
progressPercent: '(25 %, 50 %, 75 %)',
bedCooledAfterPrint: '(nach Druckabschluss)',
haSensorAlert: 'Sensor-Alarm',
haSensorAlertDescription: '(ein verknüpfter Home-Assistant-Sensor braucht Aufmerksamkeit)',
// Per-event ntfy priority (#990)
eventPriority: {
sectionTitle: 'ntfy-Priorität',

View file

@ -5456,6 +5456,61 @@ export default {
},
// Smart Plugs
haSensors: {
label: 'Sensors',
unavailable: 'Unavailable',
blocksPrints: '{{entity}} — holds prints while alerting',
states: {
open: 'Open',
closed: 'Closed',
unlocked: 'Unlocked',
locked: 'Locked',
detected: 'Detected',
clear: 'Clear',
wet: 'Wet',
dry: 'Dry',
problem: 'Problem',
ok: 'OK',
running: 'Running',
stopped: 'Stopped',
on: 'On',
off: 'Off',
},
sectionTitle: 'Home Assistant Sensors',
add: 'Add Sensor',
addTitle: 'Add Home Assistant Sensor',
editTitle: 'Edit Home Assistant Sensor',
empty: 'No sensors yet. Bind a door contact or a thermometer from Home Assistant to show it on a printer card.',
unknownPrinter: 'Unknown printer',
badgeBlocks: 'Holds prints',
badgeNotifies: 'Notifies',
badgeHidden: 'Hidden on card',
printer: 'Printer',
entity: 'Entity',
searchPlaceholder: 'Search entities...',
noEntities: 'No matching entities',
name: 'Display name',
alertWhen: 'Alert when',
alertNever: 'Never — display only',
alertAbove: 'Above',
alertBelow: 'Below',
alertHint: 'The alert state highlights the sensor on the printer card and enables the options below.',
showOnCard: 'Show on printer card',
notifyOnAlert: 'Send a notification when it starts alerting',
blockPrint: 'Hold queued prints while alerting',
blockPrintHint: 'Jobs stay queued and start on their own once the sensor clears. Ignored while Home Assistant is unreachable.',
toast: {
created: 'Sensor added',
updated: 'Sensor saved',
deleted: 'Sensor removed',
},
error: {
pickEntity: 'Pick a Home Assistant entity',
nameRequired: 'Enter a display name',
printerRequired: 'Pick a printer',
alertRequired: 'Set an alert condition first',
},
},
smartPlugs: {
offline: 'Offline',
admin: 'Admin',
@ -5768,6 +5823,8 @@ export default {
notificationEvents: 'Notification Events',
progressPercent: '(25%, 50%, 75%)',
bedCooledAfterPrint: '(after print completes)',
haSensorAlert: 'Sensor Alert',
haSensorAlertDescription: '(a bound Home Assistant sensor needs attention)',
// Per-event ntfy priority (#990)
eventPriority: {
sectionTitle: 'ntfy Priority',

View file

@ -5421,6 +5421,61 @@ export default {
},
// Smart Plugs
haSensors: {
label: 'Sensores',
unavailable: 'No disponible',
blocksPrints: '{{entity}} — retiene las impresiones mientras haya alerta',
states: {
open: 'Abierto',
closed: 'Cerrado',
unlocked: 'Desbloqueado',
locked: 'Bloqueado',
detected: 'Detectado',
clear: 'Despejado',
wet: 'Húmedo',
dry: 'Seco',
problem: 'Problema',
ok: 'OK',
running: 'En marcha',
stopped: 'Detenido',
on: 'Encendido',
off: 'Apagado',
},
sectionTitle: 'Sensores de Home Assistant',
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 contacto de puerta o un termómetro de Home Assistant para mostrarlo en la tarjeta de la impresora.',
unknownPrinter: 'Impresora desconocida',
badgeBlocks: 'Retiene impresiones',
badgeNotifies: 'Notifica',
badgeHidden: 'Oculto en la tarjeta',
printer: 'Impresora',
entity: 'Entidad',
searchPlaceholder: 'Buscar entidades...',
noEntities: 'No hay entidades coincidentes',
name: 'Nombre visible',
alertWhen: 'Alertar cuando',
alertNever: 'Nunca — solo mostrar',
alertAbove: 'Por encima de',
alertBelow: 'Por debajo de',
alertHint: 'El estado de alerta resalta el sensor en la tarjeta de la impresora y habilita las opciones de abajo.',
showOnCard: 'Mostrar en la tarjeta de la impresora',
notifyOnAlert: 'Enviar una notificación cuando empiece la alerta',
blockPrint: 'Retener las impresiones en cola mientras haya alerta',
blockPrintHint: 'Los trabajos siguen en cola y arrancan solos cuando el sensor se despeja. Se ignora mientras Home Assistant no esté accesible.',
toast: {
created: 'Sensor añadido',
updated: 'Sensor guardado',
deleted: 'Sensor eliminado',
},
error: {
pickEntity: 'Elige una entidad de Home Assistant',
nameRequired: 'Introduce un nombre visible',
printerRequired: 'Elige una impresora',
alertRequired: 'Define primero una condición de alerta',
},
},
smartPlugs: {
offline: 'Desconectado',
admin: 'Administración',
@ -5733,6 +5788,8 @@ export default {
notificationEvents: 'Eventos de notificación',
progressPercent: '(25%, 50%, 75%)',
bedCooledAfterPrint: '(después de completar la impresión)',
haSensorAlert: 'Alerta de sensor',
haSensorAlertDescription: '(un sensor de Home Assistant vinculado requiere atención)',
// Per-event ntfy priority (#990)
eventPriority: {
sectionTitle: 'Prioridad de ntfy',

View file

@ -5402,6 +5402,61 @@ export default {
},
// Smart Plugs
haSensors: {
label: 'Capteurs',
unavailable: 'Indisponible',
blocksPrints: '{{entity}} — retient les impressions tant que l\'alerte est active',
states: {
open: 'Ouvert',
closed: 'Fermé',
unlocked: 'Déverrouillé',
locked: 'Verrouillé',
detected: 'Détecté',
clear: 'Dégagé',
wet: 'Humide',
dry: 'Sec',
problem: 'Problème',
ok: 'OK',
running: 'En marche',
stopped: 'Arrêté',
on: 'Activé',
off: 'Désactivé',
},
sectionTitle: 'Capteurs Home Assistant',
add: 'Ajouter un capteur',
addTitle: 'Ajouter un capteur Home Assistant',
editTitle: 'Modifier le capteur Home Assistant',
empty: 'Aucun capteur pour l\'instant. Liez un contact de porte ou un thermomètre depuis Home Assistant pour l\'afficher sur la carte imprimante.',
unknownPrinter: 'Imprimante inconnue',
badgeBlocks: 'Retient les impressions',
badgeNotifies: 'Notifie',
badgeHidden: 'Masqué sur la carte',
printer: 'Imprimante',
entity: 'Entité',
searchPlaceholder: 'Rechercher des entités...',
noEntities: 'Aucune entité correspondante',
name: 'Nom affiché',
alertWhen: 'Alerter quand',
alertNever: 'Jamais — affichage seul',
alertAbove: 'Au-dessus de',
alertBelow: 'En dessous de',
alertHint: 'L\'état d\'alerte met le capteur en évidence sur la carte imprimante et active les options ci-dessous.',
showOnCard: 'Afficher sur la carte imprimante',
notifyOnAlert: 'Envoyer une notification au déclenchement de l\'alerte',
blockPrint: 'Retenir les impressions en file tant que l\'alerte est active',
blockPrintHint: 'Les travaux restent en file et démarrent d\'eux-mêmes une fois le capteur revenu à la normale. Ignoré tant que Home Assistant est injoignable.',
toast: {
created: 'Capteur ajouté',
updated: 'Capteur enregistré',
deleted: 'Capteur supprimé',
},
error: {
pickEntity: 'Choisissez une entité Home Assistant',
nameRequired: 'Saisissez un nom affiché',
printerRequired: 'Choisissez une imprimante',
alertRequired: 'Définissez d\'abord une condition d\'alerte',
},
},
smartPlugs: {
offline: 'Hors ligne',
admin: 'Administrateur',
@ -5714,6 +5769,8 @@ export default {
notificationEvents: 'Événements de notification',
progressPercent: '(25 %, 50 %, 75 %)',
bedCooledAfterPrint: '(après la fin de l\'impression)',
haSensorAlert: 'Alerte de capteur',
haSensorAlertDescription: '(un capteur Home Assistant lié nécessite votre attention)',
// Per-event ntfy priority (#990)
eventPriority: {
sectionTitle: 'Priorité ntfy',

View file

@ -5401,6 +5401,61 @@ export default {
},
// Smart Plugs
haSensors: {
label: 'Sensori',
unavailable: 'Non disponibile',
blocksPrints: '{{entity}} — trattiene le stampe finché l\'allarme è attivo',
states: {
open: 'Aperto',
closed: 'Chiuso',
unlocked: 'Sbloccato',
locked: 'Bloccato',
detected: 'Rilevato',
clear: 'Libero',
wet: 'Bagnato',
dry: 'Asciutto',
problem: 'Problema',
ok: 'OK',
running: 'In funzione',
stopped: 'Fermo',
on: 'Acceso',
off: 'Spento',
},
sectionTitle: 'Sensori Home Assistant',
add: 'Aggiungi sensore',
addTitle: 'Aggiungi sensore Home Assistant',
editTitle: 'Modifica sensore Home Assistant',
empty: 'Nessun sensore. Collega un contatto porta o un termometro da Home Assistant per mostrarlo sulla scheda stampante.',
unknownPrinter: 'Stampante sconosciuta',
badgeBlocks: 'Trattiene le stampe',
badgeNotifies: 'Notifica',
badgeHidden: 'Nascosto sulla scheda',
printer: 'Stampante',
entity: 'Entità',
searchPlaceholder: 'Cerca entità...',
noEntities: 'Nessuna entità corrispondente',
name: 'Nome visualizzato',
alertWhen: 'Allarme quando',
alertNever: 'Mai — solo visualizzazione',
alertAbove: 'Sopra',
alertBelow: 'Sotto',
alertHint: 'Lo stato di allarme evidenzia il sensore sulla scheda stampante e abilita le opzioni sottostanti.',
showOnCard: 'Mostra sulla scheda stampante',
notifyOnAlert: 'Invia una notifica quando scatta l\'allarme',
blockPrint: 'Trattieni le stampe in coda finché l\'allarme è attivo',
blockPrintHint: 'I lavori restano in coda e partono da soli quando il sensore rientra. Ignorato finché Home Assistant non è raggiungibile.',
toast: {
created: 'Sensore aggiunto',
updated: 'Sensore salvato',
deleted: 'Sensore rimosso',
},
error: {
pickEntity: 'Scegli un\'entità Home Assistant',
nameRequired: 'Inserisci un nome visualizzato',
printerRequired: 'Scegli una stampante',
alertRequired: 'Imposta prima una condizione di allarme',
},
},
smartPlugs: {
offline: 'Offline',
admin: 'Amministrazione',
@ -5713,6 +5768,8 @@ export default {
notificationEvents: 'Eventi di notifica',
progressPercent: '(25%, 50%, 75%)',
bedCooledAfterPrint: '(dopo il completamento della stampa)',
haSensorAlert: 'Avviso sensore',
haSensorAlertDescription: '(un sensore Home Assistant collegato richiede attenzione)',
// Per-event ntfy priority (#990)
eventPriority: {
sectionTitle: 'Priorità ntfy',

View file

@ -5413,6 +5413,61 @@ export default {
},
// Smart Plugs
haSensors: {
label: 'センサー',
unavailable: '利用不可',
blocksPrints: '{{entity}} — アラート中は印刷を保留します',
states: {
open: '開',
closed: '閉',
unlocked: '解錠',
locked: '施錠',
detected: '検知',
clear: 'なし',
wet: '濡れ',
dry: '乾燥',
problem: '異常',
ok: '正常',
running: '作動中',
stopped: '停止',
on: 'オン',
off: 'オフ',
},
sectionTitle: 'Home Assistant センサー',
add: 'センサーを追加',
addTitle: 'Home Assistant センサーを追加',
editTitle: 'Home Assistant センサーを編集',
empty: 'センサーがまだありません。Home Assistant のドアセンサーや温度計を連携すると、プリンターカードに表示されます。',
unknownPrinter: '不明なプリンター',
badgeBlocks: '印刷を保留',
badgeNotifies: '通知する',
badgeHidden: 'カードに非表示',
printer: 'プリンター',
entity: 'エンティティ',
searchPlaceholder: 'エンティティを検索...',
noEntities: '一致するエンティティがありません',
name: '表示名',
alertWhen: 'アラート条件',
alertNever: 'なし — 表示のみ',
alertAbove: 'しきい値超過',
alertBelow: 'しきい値未満',
alertHint: 'アラート状態になるとプリンターカードで強調表示され、下のオプションが有効になります。',
showOnCard: 'プリンターカードに表示',
notifyOnAlert: 'アラート発生時に通知を送信',
blockPrint: 'アラート中はキューの印刷を保留',
blockPrintHint: 'ジョブはキューに残り、センサーが解除されると自動的に開始します。Home Assistant に接続できない間は無視されます。',
toast: {
created: 'センサーを追加しました',
updated: 'センサーを保存しました',
deleted: 'センサーを削除しました',
},
error: {
pickEntity: 'Home Assistant のエンティティを選択してください',
nameRequired: '表示名を入力してください',
printerRequired: 'プリンターを選択してください',
alertRequired: '先にアラート条件を設定してください',
},
},
smartPlugs: {
offline: 'オフライン',
admin: '管理',
@ -5725,6 +5780,8 @@ export default {
notificationEvents: '通知イベント',
progressPercent: '25%、50%、75%',
bedCooledAfterPrint: '(印刷完了後)',
haSensorAlert: 'センサーアラート',
haSensorAlertDescription: '(連携した Home Assistant センサーに注意が必要です)',
// Per-event ntfy priority (#990)
eventPriority: {
sectionTitle: 'ntfy 優先度',

View file

@ -5144,6 +5144,61 @@ export default {
replaceCarbonFilter: '활성탄 필터 교체',
lubricateLeftNozzleRail: '왼쪽 노즐 레일 윤활 (H2 시리즈)'
},
haSensors: {
label: '센서',
unavailable: '사용 불가',
blocksPrints: '{{entity}} — 경고 중에는 인쇄를 보류합니다',
states: {
open: '열림',
closed: '닫힘',
unlocked: '잠금 해제',
locked: '잠김',
detected: '감지됨',
clear: '없음',
wet: '젖음',
dry: '건조',
problem: '문제',
ok: '정상',
running: '작동 중',
stopped: '정지',
on: '켜짐',
off: '꺼짐',
},
sectionTitle: 'Home Assistant 센서',
add: '센서 추가',
addTitle: 'Home Assistant 센서 추가',
editTitle: 'Home Assistant 센서 편집',
empty: '아직 센서가 없습니다. Home Assistant의 도어 센서나 온도계를 연결하면 프린터 카드에 표시됩니다.',
unknownPrinter: '알 수 없는 프린터',
badgeBlocks: '인쇄 보류',
badgeNotifies: '알림',
badgeHidden: '카드에서 숨김',
printer: '프린터',
entity: '엔터티',
searchPlaceholder: '엔터티 검색...',
noEntities: '일치하는 엔터티 없음',
name: '표시 이름',
alertWhen: '경고 조건',
alertNever: '없음 — 표시만',
alertAbove: '초과',
alertBelow: '미만',
alertHint: '경고 상태가 되면 프린터 카드에서 강조되고 아래 옵션이 활성화됩니다.',
showOnCard: '프린터 카드에 표시',
notifyOnAlert: '경고가 시작되면 알림 보내기',
blockPrint: '경고 중에는 대기 중인 인쇄를 보류',
blockPrintHint: '작업은 대기열에 남아 있다가 센서가 해제되면 자동으로 시작됩니다. Home Assistant에 연결할 수 없는 동안에는 무시됩니다.',
toast: {
created: '센서를 추가했습니다',
updated: '센서를 저장했습니다',
deleted: '센서를 삭제했습니다',
},
error: {
pickEntity: 'Home Assistant 엔터티를 선택하세요',
nameRequired: '표시 이름을 입력하세요',
printerRequired: '프린터를 선택하세요',
alertRequired: '먼저 경고 조건을 설정하세요',
},
},
smartPlugs: {
offline: '오프라인',
admin: '관리자',
@ -5439,6 +5494,8 @@ export default {
notificationEvents: '알림 이벤트',
progressPercent: '(25%, 50%, 75%)',
bedCooledAfterPrint: '(인쇄 완료 후)',
haSensorAlert: '센서 경고',
haSensorAlertDescription: '(연결된 Home Assistant 센서에 주의가 필요합니다)',
eventPriority: {
sectionTitle: 'ntfy 우선순위',
helpNtfy: '각 활성화된 이벤트에 대한 우선순위를 선택하세요. ntfy는 이를 사용하여 알림을 에스컬레이션합니다(소리, 가시성, 푸시 동작). 여기서 설정되지 않은 수준은 ntfy 서버 기본값을 사용합니다.',

View file

@ -5401,6 +5401,61 @@ export default {
},
// Smart Plugs
haSensors: {
label: 'Sensores',
unavailable: 'Indisponível',
blocksPrints: '{{entity}} — segura as impressões enquanto houver alerta',
states: {
open: 'Aberto',
closed: 'Fechado',
unlocked: 'Destrancado',
locked: 'Trancado',
detected: 'Detectado',
clear: 'Livre',
wet: 'Molhado',
dry: 'Seco',
problem: 'Problema',
ok: 'OK',
running: 'Em funcionamento',
stopped: 'Parado',
on: 'Ligado',
off: 'Desligado',
},
sectionTitle: 'Sensores do Home Assistant',
add: 'Adicionar sensor',
addTitle: 'Adicionar sensor do Home Assistant',
editTitle: 'Editar sensor do Home Assistant',
empty: 'Nenhum sensor ainda. Vincule um contato de porta ou um termômetro do Home Assistant para exibi-lo no cartão da impressora.',
unknownPrinter: 'Impressora desconhecida',
badgeBlocks: 'Segura impressões',
badgeNotifies: 'Notifica',
badgeHidden: 'Oculto no cartão',
printer: 'Impressora',
entity: 'Entidade',
searchPlaceholder: 'Buscar entidades...',
noEntities: 'Nenhuma entidade correspondente',
name: 'Nome exibido',
alertWhen: 'Alertar quando',
alertNever: 'Nunca — apenas exibir',
alertAbove: 'Acima de',
alertBelow: 'Abaixo de',
alertHint: 'O estado de alerta destaca o sensor no cartão da impressora e habilita as opções abaixo.',
showOnCard: 'Mostrar no cartão da impressora',
notifyOnAlert: 'Enviar uma notificação quando o alerta começar',
blockPrint: 'Segurar as impressões na fila enquanto houver alerta',
blockPrintHint: 'Os trabalhos permanecem na fila e começam sozinhos quando o sensor normaliza. Ignorado enquanto o Home Assistant estiver inacessível.',
toast: {
created: 'Sensor adicionado',
updated: 'Sensor salvo',
deleted: 'Sensor removido',
},
error: {
pickEntity: 'Escolha uma entidade do Home Assistant',
nameRequired: 'Informe um nome exibido',
printerRequired: 'Escolha uma impressora',
alertRequired: 'Defina primeiro uma condição de alerta',
},
},
smartPlugs: {
offline: 'Offline',
admin: 'Administrador',
@ -5713,6 +5768,8 @@ export default {
notificationEvents: 'Eventos de Notificação',
progressPercent: '(25%, 50%, 75%)',
bedCooledAfterPrint: '(após conclusão da impressão)',
haSensorAlert: 'Alerta de sensor',
haSensorAlertDescription: '(um sensor do Home Assistant vinculado precisa de atenção)',
// Per-event ntfy priority (#990)
eventPriority: {
sectionTitle: 'Prioridade ntfy',

View file

@ -5132,6 +5132,61 @@ export default {
replaceCarbonFilter: "Заменить угольный фильтр",
lubricateLeftNozzleRail: "Смазать направляющую левого сопла (серия H2)",
},
haSensors: {
label: "Датчики",
unavailable: "Недоступно",
blocksPrints: "{{entity}} — удерживает печать, пока активно оповещение",
states: {
open: "Открыто",
closed: "Закрыто",
unlocked: "Разблокировано",
locked: "Заблокировано",
detected: "Обнаружено",
clear: "Чисто",
wet: "Влажно",
dry: "Сухо",
problem: "Проблема",
ok: "Норма",
running: "Работает",
stopped: "Остановлено",
on: "Вкл",
off: "Выкл",
},
sectionTitle: "Датчики Home Assistant",
add: "Добавить датчик",
addTitle: "Добавить датчик Home Assistant",
editTitle: "Изменить датчик Home Assistant",
empty: "Датчиков пока нет. Свяжите дверной контакт или термометр из Home Assistant, чтобы показать его на карточке принтера.",
unknownPrinter: "Неизвестный принтер",
badgeBlocks: "Удерживает печать",
badgeNotifies: "Уведомляет",
badgeHidden: "Скрыт на карточке",
printer: "Принтер",
entity: "Сущность",
searchPlaceholder: "Поиск сущностей...",
noEntities: "Подходящих сущностей нет",
name: "Отображаемое имя",
alertWhen: "Оповещать когда",
alertNever: "Никогда — только показывать",
alertAbove: "Выше",
alertBelow: "Ниже",
alertHint: "Состояние оповещения подсвечивает датчик на карточке принтера и включает параметры ниже.",
showOnCard: "Показывать в карточке принтера",
notifyOnAlert: "Отправлять уведомление при срабатывании",
blockPrint: "Удерживать печать в очереди, пока активно оповещение",
blockPrintHint: "Задания остаются в очереди и запускаются сами, когда датчик приходит в норму. Игнорируется, пока Home Assistant недоступен.",
toast: {
created: "Датчик добавлен",
updated: "Датчик сохранён",
deleted: "Датчик удалён",
},
error: {
pickEntity: "Выберите сущность Home Assistant",
nameRequired: "Введите отображаемое имя",
printerRequired: "Выберите принтер",
alertRequired: "Сначала задайте условие оповещения",
},
},
smartPlugs: {
offline: "Не в сети",
admin: "Управление",
@ -5426,6 +5481,8 @@ export default {
notificationEvents: "События для уведомлений",
progressPercent: "(25%, 50%, 75%)",
bedCooledAfterPrint: "(после завершения печати)",
haSensorAlert: "Оповещение датчика",
haSensorAlertDescription: "(связанный датчик Home Assistant требует внимания)",
eventPriority: {
sectionTitle: "Приоритет ntfy",
helpNtfy: "Выберите приоритет для каждого включённого события. ntfy использует его для усиления оповещений: звука, видимости и поведения push-уведомлений. Для неуказанных событий используется приоритет по умолчанию сервера ntfy.",

View file

@ -5377,6 +5377,61 @@ export default {
},
// Akıllı Prizler
haSensors: {
label: 'Sensörler',
unavailable: 'Kullanılamıyor',
blocksPrints: '{{entity}} — uyarı sürerken baskıları bekletir',
states: {
open: 'Açık',
closed: 'Kapalı',
unlocked: 'Kilit açık',
locked: 'Kilitli',
detected: 'Algılandı',
clear: 'Temiz',
wet: 'Islak',
dry: 'Kuru',
problem: 'Sorun',
ok: 'Normal',
running: 'Çalışıyor',
stopped: 'Durdu',
on: 'Açık',
off: 'Kapalı',
},
sectionTitle: 'Home Assistant Sensörleri',
add: 'Sensör ekle',
addTitle: 'Home Assistant sensörü ekle',
editTitle: 'Home Assistant sensörünü düzenle',
empty: 'Henüz sensör yok. Yazıcı kartında göstermek için Home Assistant\'tan bir kapı kontağı veya termometre bağlayın.',
unknownPrinter: 'Bilinmeyen yazıcı',
badgeBlocks: 'Baskıları bekletir',
badgeNotifies: 'Bildirir',
badgeHidden: 'Kartta gizli',
printer: 'Yazıcı',
entity: 'Varlık',
searchPlaceholder: 'Varlık ara...',
noEntities: 'Eşleşen varlık yok',
name: 'Görünen ad',
alertWhen: 'Şu durumda uyar',
alertNever: 'Asla — yalnızca göster',
alertAbove: 'Şunun üstünde',
alertBelow: 'Şunun altında',
alertHint: 'Uyarı durumu sensörü yazıcı kartında vurgular ve aşağıdaki seçenekleri etkinleştirir.',
showOnCard: 'Yazıcı kartında göster',
notifyOnAlert: 'Uyarı başladığında bildirim gönder',
blockPrint: 'Uyarı sürerken kuyruktaki baskıları beklet',
blockPrintHint: 'İşler kuyrukta kalır ve sensör normale döndüğünde kendiliğinden başlar. Home Assistant\'a ulaşılamadığı sürece yok sayılır.',
toast: {
created: 'Sensör eklendi',
updated: 'Sensör kaydedildi',
deleted: 'Sensör kaldırıldı',
},
error: {
pickEntity: 'Bir Home Assistant varlığı seçin',
nameRequired: 'Bir görünen ad girin',
printerRequired: 'Bir yazıcı seçin',
alertRequired: 'Önce bir uyarı koşulu belirleyin',
},
},
smartPlugs: {
offline: 'Çevrimdışı',
admin: 'Yönetici',
@ -5675,6 +5730,8 @@ export default {
notificationEvents: 'Bildirim Olayları',
progressPercent: '(%25, %50, %75)',
bedCooledAfterPrint: '(baskı tamamlandıktan sonra)',
haSensorAlert: 'Sensör Uyarısı',
haSensorAlertDescription: '(bağlı bir 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.',

View file

@ -5456,6 +5456,61 @@ export default {
},
// Smart Plugs
haSensors: {
label: "Датчики",
unavailable: "Недоступно",
blocksPrints: "{{entity}} — утримує друк, поки триває сповіщення",
states: {
open: "Відчинено",
closed: "Зачинено",
unlocked: "Розблоковано",
locked: "Заблоковано",
detected: "Виявлено",
clear: "Чисто",
wet: "Волого",
dry: "Сухо",
problem: "Проблема",
ok: "Норма",
running: "Працює",
stopped: "Зупинено",
on: "Увімк",
off: "Вимк",
},
sectionTitle: "Датчики Home Assistant",
add: "Додати датчик",
addTitle: "Додати датчик Home Assistant",
editTitle: "Редагувати датчик Home Assistant",
empty: "Датчиків ще немає. Прив'яжіть дверний контакт або термометр із Home Assistant, щоб показати його на картці принтера.",
unknownPrinter: "Невідомий принтер",
badgeBlocks: "Утримує друк",
badgeNotifies: "Сповіщає",
badgeHidden: "Приховано на картці",
printer: "Принтер",
entity: "Сутність",
searchPlaceholder: "Пошук сутностей...",
noEntities: "Немає відповідних сутностей",
name: "Відображувана назва",
alertWhen: "Сповіщати коли",
alertNever: "Ніколи — лише показувати",
alertAbove: "Вище",
alertBelow: "Нижче",
alertHint: "Стан сповіщення підсвічує датчик на картці принтера та вмикає параметри нижче.",
showOnCard: "Показувати на картці принтера",
notifyOnAlert: "Надсилати сповіщення, коли спрацьовує",
blockPrint: "Утримувати друк у черзі, поки триває сповіщення",
blockPrintHint: "Завдання лишаються в черзі й запускаються самі, коли датчик повертається до норми. Ігнорується, поки Home Assistant недоступний.",
toast: {
created: "Датчик додано",
updated: "Датчик збережено",
deleted: "Датчик видалено",
},
error: {
pickEntity: "Оберіть сутність Home Assistant",
nameRequired: "Введіть відображувану назву",
printerRequired: "Оберіть принтер",
alertRequired: "Спершу задайте умову сповіщення",
},
},
smartPlugs: {
offline: "Не в мережі",
admin: "Адміністрування",
@ -5768,6 +5823,8 @@ export default {
notificationEvents: "Події сповіщень",
progressPercent: "(25%, 50%, 75%)",
bedCooledAfterPrint: "(після завершення друку)",
haSensorAlert: "Сповіщення датчика",
haSensorAlertDescription: "(пов'язаний датчик Home Assistant потребує уваги)",
// Per-event ntfy priority (#990)
eventPriority: {
sectionTitle: "Пріоритет ntfy",

View file

@ -5401,6 +5401,61 @@ export default {
},
// Smart Plugs
haSensors: {
label: '传感器',
unavailable: '不可用',
blocksPrints: '{{entity}} — 警报期间暂停打印',
states: {
open: '打开',
closed: '关闭',
unlocked: '已解锁',
locked: '已锁定',
detected: '已检测',
clear: '无',
wet: '潮湿',
dry: '干燥',
problem: '异常',
ok: '正常',
running: '运行中',
stopped: '已停止',
on: '开',
off: '关',
},
sectionTitle: 'Home Assistant 传感器',
add: '添加传感器',
addTitle: '添加 Home Assistant 传感器',
editTitle: '编辑 Home Assistant 传感器',
empty: '还没有传感器。绑定 Home Assistant 的门磁或温度计,即可显示在打印机卡片上。',
unknownPrinter: '未知打印机',
badgeBlocks: '暂停打印',
badgeNotifies: '发送通知',
badgeHidden: '卡片上隐藏',
printer: '打印机',
entity: '实体',
searchPlaceholder: '搜索实体...',
noEntities: '没有匹配的实体',
name: '显示名称',
alertWhen: '警报条件',
alertNever: '从不 — 仅显示',
alertAbove: '高于',
alertBelow: '低于',
alertHint: '进入警报状态后会在打印机卡片上高亮显示,并启用下面的选项。',
showOnCard: '在打印机卡片上显示',
notifyOnAlert: '触发警报时发送通知',
blockPrint: '警报期间暂停队列中的打印',
blockPrintHint: '任务会留在队列中传感器恢复后自动开始。Home Assistant 无法连接时忽略此设置。',
toast: {
created: '已添加传感器',
updated: '已保存传感器',
deleted: '已删除传感器',
},
error: {
pickEntity: '请选择一个 Home Assistant 实体',
nameRequired: '请输入显示名称',
printerRequired: '请选择一台打印机',
alertRequired: '请先设置警报条件',
},
},
smartPlugs: {
offline: '离线',
admin: '管理',
@ -5713,6 +5768,8 @@ export default {
notificationEvents: '通知事件',
progressPercent: '25%、50%、75%',
bedCooledAfterPrint: '(打印完成后)',
haSensorAlert: '传感器警报',
haSensorAlertDescription: '(已绑定的 Home Assistant 传感器需要关注)',
// Per-event ntfy priority (#990)
eventPriority: {
sectionTitle: 'ntfy 优先级',

View file

@ -5401,6 +5401,61 @@ export default {
},
// Smart Plugs
haSensors: {
label: '感測器',
unavailable: '無法使用',
blocksPrints: '{{entity}} — 警報期間暫停列印',
states: {
open: '開啟',
closed: '關閉',
unlocked: '已解鎖',
locked: '已鎖定',
detected: '已偵測',
clear: '無',
wet: '潮濕',
dry: '乾燥',
problem: '異常',
ok: '正常',
running: '運轉中',
stopped: '已停止',
on: '開',
off: '關',
},
sectionTitle: 'Home Assistant 感測器',
add: '新增感測器',
addTitle: '新增 Home Assistant 感測器',
editTitle: '編輯 Home Assistant 感測器',
empty: '還沒有感測器。綁定 Home Assistant 的門磁或溫度計,即可顯示在印表機卡片上。',
unknownPrinter: '未知印表機',
badgeBlocks: '暫停列印',
badgeNotifies: '發送通知',
badgeHidden: '卡片上隱藏',
printer: '印表機',
entity: '實體',
searchPlaceholder: '搜尋實體...',
noEntities: '沒有相符的實體',
name: '顯示名稱',
alertWhen: '警報條件',
alertNever: '永不 — 僅顯示',
alertAbove: '高於',
alertBelow: '低於',
alertHint: '進入警報狀態後會在印表機卡片上醒目顯示,並啟用下方選項。',
showOnCard: '在印表機卡片上顯示',
notifyOnAlert: '觸發警報時發送通知',
blockPrint: '警報期間暫停佇列中的列印',
blockPrintHint: '工作會留在佇列中感測器恢復後自動開始。Home Assistant 無法連線時會忽略此設定。',
toast: {
created: '已新增感測器',
updated: '已儲存感測器',
deleted: '已移除感測器',
},
error: {
pickEntity: '請選擇一個 Home Assistant 實體',
nameRequired: '請輸入顯示名稱',
printerRequired: '請選擇一台印表機',
alertRequired: '請先設定警報條件',
},
},
smartPlugs: {
offline: '離線',
admin: '管理',
@ -5713,6 +5768,8 @@ export default {
notificationEvents: '通知事件',
progressPercent: '25%、50%、75%',
bedCooledAfterPrint: '(列印完成後)',
haSensorAlert: '感測器警報',
haSensorAlertDescription: '(已綁定的 Home Assistant 感測器需要注意)',
// Per-event ntfy priority (#990)
eventPriority: {
sectionTitle: 'ntfy 優先級',

View file

@ -110,6 +110,7 @@ import { MQTTDebugModal } from '../components/MQTTDebugModal';
import { HMSErrorModal, filterKnownHMSErrors } from '../components/HMSErrorModal';
import { AiDetectionModal } from '../components/AiDetectionModal';
import { PrinterQueueWidget } from '../components/PrinterQueueWidget';
import { PrinterHASensorRow } from '../components/PrinterHASensorRow';
import { AMSHistoryModal } from '../components/AMSHistoryModal';
import { AmsBackupModal } from '../components/AmsBackupModal';
import { HeaterHistoryModal } from '../components/HeaterHistoryModal';
@ -6018,6 +6019,11 @@ function PrinterCard({
</div>
)}
{/* Home Assistant sensors (#1148). Outside the smartPlug block above:
a printer can have an enclosure door contact without having a plug,
and nesting it there would hide the row on exactly those setups. */}
<PrinterHASensorRow printerId={printer.id} />
{/* Connection Info & Actions */}
<div className="pt-4">
<div className="mb-3 h-[2px] bg-bambu-dark-tertiary" />

View file

@ -11,7 +11,7 @@ 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 { PreheatFilamentTargetsEditor } from '../components/PreheatFilamentTargetsEditor';
import type { APIKey, AppSettings, AppSettingsUpdate, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus, GitHubBackupStatus, CloudAuthStatus, UserCreate, UserUpdate, UserResponse, StorageUsageResponse, CalibrationMode } from '../api/client';
import type { APIKey, AppSettings, AppSettingsUpdate, PrinterHASensor, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus, GitHubBackupStatus, CloudAuthStatus, UserCreate, UserUpdate, UserResponse, StorageUsageResponse, CalibrationMode } from '../api/client';
import { Card, CardContent, CardDensityProvider, CardHeader } from '../components/Card';
import { SlicerBundlesPanel } from '../components/SlicerBundlesPanel';
import { SlicerPipelinesPanel } from '../components/SlicerPipelinesPanel';
@ -22,6 +22,7 @@ import { CopyButton } from '../components/CopyButton';
import { Button } from '../components/Button';
import { SmartPlugCard } from '../components/SmartPlugCard';
import { AddSmartPlugModal } from '../components/AddSmartPlugModal';
import { HASensorModal } from '../components/HASensorModal';
import { NotificationProviderCard } from '../components/NotificationProviderCard';
import { AddNotificationModal } from '../components/AddNotificationModal';
import { NotificationTemplateEditor } from '../components/NotificationTemplateEditor';
@ -50,7 +51,7 @@ 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 { Palette } from 'lucide-react';
import { Gauge, Palette } from 'lucide-react';
import { registerSettingsSearch, getSettingsSearchEntries } from '../lib/settingsSearch';
import type { UsersSubTab } from '../lib/settingsSearch';
@ -184,6 +185,8 @@ export function SettingsPage() {
const [humidityDrafts, setHumidityDrafts] = useState<Record<string, string>>({});
const [showPlugModal, setShowPlugModal] = useState(false);
const [editingPlug, setEditingPlug] = useState<SmartPlug | null>(null);
const [showHASensorModal, setShowHASensorModal] = useState(false);
const [editingHASensor, setEditingHASensor] = useState<PrinterHASensor | null>(null);
const [showNotificationModal, setShowNotificationModal] = useState(false);
const [editingProvider, setEditingProvider] = useState<NotificationProvider | null>(null);
const [editingTemplate, setEditingTemplate] = useState<NotificationTemplate | null>(null);
@ -434,6 +437,12 @@ export function SettingsPage() {
queryFn: api.getPrinters,
});
const { data: haSensors } = useQuery({
queryKey: ['haSensors'],
queryFn: () => api.getHASensors(),
enabled: activeTab === 'plugs',
});
// A business-sized fleet gets the commercial ask instead of the donation ask.
const sponsorAudience = fleetAudience(printers?.length ?? 0);
@ -3523,6 +3532,88 @@ export function SettingsPage() {
</CardContent>
</Card>
)}
{/* 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">
<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" />
{t('haSensors.sectionTitle')}
</h2>
<Button
className="whitespace-nowrap"
disabled={!printers?.length}
onClick={() => {
setEditingHASensor(null);
setShowHASensorModal(true);
}}
>
<Plus className="w-4 h-4" />
{t('haSensors.add')}
</Button>
</div>
{haSensors && haSensors.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{haSensors.map((sensor) => {
const printer = printers?.find((p) => p.id === sensor.printer_id);
return (
<Card key={sensor.id}>
<CardContent className="py-4">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="text-white font-medium truncate">{sensor.name}</div>
<div className="text-xs text-bambu-gray truncate">{sensor.entity_id}</div>
<div className="text-xs text-bambu-gray mt-1">
{printer?.name ?? t('haSensors.unknownPrinter')}
</div>
</div>
<Button
size="sm"
variant="secondary"
onClick={() => {
setEditingHASensor(sensor);
setShowHASensorModal(true);
}}
>
{t('common.edit')}
</Button>
</div>
<div className="flex flex-wrap gap-1 mt-3">
{sensor.block_print && (
<span className="px-2 py-0.5 text-xs rounded bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-400">
{t('haSensors.badgeBlocks')}
</span>
)}
{sensor.notify_on_alert && (
<span className="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')}
</span>
)}
{!sensor.show_on_printer_card && (
<span className="px-2 py-0.5 text-xs rounded bg-bambu-dark-tertiary text-bambu-gray">
{t('haSensors.badgeHidden')}
</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('haSensors.empty')}</p>
</div>
</CardContent>
</Card>
)}
</div>
</div>
)}
@ -5552,6 +5643,18 @@ export function SettingsPage() {
/>
)}
{/* Home Assistant Sensor Modal (#1148) */}
{showHASensorModal && (
<HASensorModal
sensor={editingHASensor}
printers={printers ?? []}
onClose={() => {
setShowHASensorModal(false);
setEditingHASensor(null);
}}
/>
)}
{/* Notification Modal */}
{showNotificationModal && (
<AddNotificationModal

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -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-fpDLI8Il.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DDSj68H5.css">
<script type="module" crossorigin src="/assets/index-BAVjF7qG.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Db2rfQf-.css">
</head>
<body>
<div id="root"></div>