mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 18:47:13 +02:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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') });
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 優先度',
|
||||
|
||||
@@ -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 서버 기본값을 사용합니다.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 优先级',
|
||||
|
||||
@@ -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 優先級',
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user