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
@@ -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();
});
});
@@ -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();
});
});
+88
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', {
@@ -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') });
+413
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>
);
}
@@ -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
@@ -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>
);
}
+57
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',
+57
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',
+57
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',
+57
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',
+57
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',
+57
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 優先度',
+57
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 서버 기본값을 사용합니다.',
+57
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',
+57
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.",
+57
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.',
+57
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",
+57
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 优先级',
+57
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 優先級',
+6
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" />
+105 -2
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