Merge branch 'dev' into feature/save-ams-mapping-toggle

This commit is contained in:
MartinNYHC
2026-07-31 15:39:13 +02:00
committed by GitHub
63 changed files with 4647 additions and 493 deletions
@@ -536,6 +536,198 @@ describe('ConfigureAmsSlotModal', () => {
});
});
describe('Generic presets and the K-profile picker (#2710)', () => {
// Reporter's printer: nine Flow-Dynamics entries, every one of them
// calibrated against Generic PLA (filament_id GFL99) and named after the
// spool's colour rather than its material. Bambu Studio lists all nine for
// a Generic PLA slot; Bambuddy showed only the one already bound to the
// slot via cali_idx.
const genericPlaProfiles = [
'Black PLA+', 'Dark Brown', 'Glow', 'Gray', 'Lt Brown',
'Marble', 'Orange PLA', 'Sunlu White PLA+', 'White PLA+ Duramic',
].map((name, i) => ({
slot_id: i + 1,
extruder_id: 0,
nozzle_id: 'HH00-0.4',
nozzle_diameter: '0.4',
filament_id: 'GFL99',
name,
k_value: `0.0${30 + i}`,
n_coef: '0',
ams_id: 0,
tray_id: 0,
setting_id: '',
}));
const genericPlaSlot = {
...defaultProps.slotInfo,
savedPresetId: 'builtin_GFL99',
extruderId: 0,
};
beforeEach(() => {
(api.getBuiltinFilaments as ReturnType<typeof vi.fn>).mockResolvedValue([
{ filament_id: 'GFL99', name: 'Generic PLA', filament_type: 'PLA' },
]);
(api.getKProfiles as ReturnType<typeof vi.fn>).mockResolvedValue({
profiles: genericPlaProfiles,
});
});
it('offers every Generic PLA profile when the slot preset is Generic PLA', async () => {
render(<ConfigureAmsSlotModal {...defaultProps} slotInfo={genericPlaSlot} />);
await waitFor(() => {
expect(screen.getByRole('option', { name: /Dark Brown/ })).toBeInTheDocument();
});
// All nine, not just the one bound via cali_idx — matching the printer's
// own calibration table for GFL99.
for (const profile of genericPlaProfiles) {
expect(
screen.getByRole('option', { name: `${profile.name} (K=${profile.k_value})` }),
).toBeInTheDocument();
}
});
it('offers them on a freshly reset slot with no active cali_idx', async () => {
// "When I reset the AMS slot, the generic PLA shows no k-values at all."
// With no cali_idx the #1689 safety net has nothing to surface, so the
// list came back empty; the id match has to stand on its own.
render(
<ConfigureAmsSlotModal
{...defaultProps}
slotInfo={{ ...genericPlaSlot, caliIdx: 0 }}
/>
);
await waitFor(() => {
expect(screen.getByRole('option', { name: /Marble/ })).toBeInTheDocument();
});
expect(screen.getByRole('option', { name: /Glow/ })).toBeInTheDocument();
});
it('matches on name when the profile carries no filament_id at all', async () => {
// Not every firmware fills filament_id in extrusion_cali_get. "Generic"
// is not a brand, so the name path must fall back to the material
// instead of demanding "GENERIC" in the profile name.
(api.getKProfiles as ReturnType<typeof vi.fn>).mockResolvedValue({
profiles: [
{ ...genericPlaProfiles[0], filament_id: '', name: 'Orange PLA' },
{ ...genericPlaProfiles[1], filament_id: '', name: 'Dark Brown' },
],
});
render(<ConfigureAmsSlotModal {...defaultProps} slotInfo={genericPlaSlot} />);
await waitFor(() => {
expect(screen.getByRole('option', { name: /Orange PLA/ })).toBeInTheDocument();
});
});
it('does not sweep generic profiles into a brand preset', async () => {
// Guard against the id path widening: "Bambu PLA Basic" is GFL05, so
// GFL99 profiles must still be filtered out of the matching group and
// only reachable through the explicit "other profiles" group.
render(
<ConfigureAmsSlotModal
{...defaultProps}
slotInfo={{ ...defaultProps.slotInfo, savedPresetId: 'GFSL05_09', extruderId: 0 }}
/>
);
await waitFor(() => {
expect(screen.getByText('Bambu PLA Basic @BBL X1C')).toBeInTheDocument();
});
// Every GFL99 profile is demoted to the other group: the brand gate on
// "Bambu" still applies, and none of these names carry it.
for (const profile of genericPlaProfiles) {
const option = screen.getByRole('option', { name: `${profile.name} (K=${profile.k_value})` });
expect(option.closest('optgroup')).toHaveAttribute(
'label',
'Other K profiles on this printer',
);
}
});
it("offers the printer's other profiles even when nothing matches the preset", async () => {
// The escape hatch: a PETG preset matches none of the PLA profiles, but
// the user can still reach every profile the printer holds instead of
// being sent to the slicer.
(api.getBuiltinFilaments as ReturnType<typeof vi.fn>).mockResolvedValue([
{ filament_id: 'GFG99', name: 'Generic PETG', filament_type: 'PETG' },
]);
render(
<ConfigureAmsSlotModal
{...defaultProps}
slotInfo={{ ...genericPlaSlot, savedPresetId: 'builtin_GFG99', caliIdx: 0 }}
/>
);
await waitFor(() => {
expect(screen.getByRole('option', { name: /Dark Brown/ })).toBeInTheDocument();
});
expect(screen.getByRole('option', { name: /Dark Brown/ }).closest('optgroup')).toHaveAttribute(
'label',
'Other K profiles on this printer',
);
});
it('sends the cali_idx of a profile picked from the other-profiles group', async () => {
(api.getBuiltinFilaments as ReturnType<typeof vi.fn>).mockResolvedValue([
{ filament_id: 'GFG99', name: 'Generic PETG', filament_type: 'PETG' },
]);
render(
<ConfigureAmsSlotModal
{...defaultProps}
slotInfo={{ ...genericPlaSlot, savedPresetId: 'builtin_GFG99', caliIdx: 0 }}
/>
);
await waitFor(() => {
expect(screen.getByRole('option', { name: /Marble/ })).toBeInTheDocument();
});
const marble = genericPlaProfiles.find(p => p.name === 'Marble')!;
fireEvent.change(screen.getByRole('combobox'), {
target: { value: `${marble.name}|${marble.k_value}` },
});
fireEvent.click(screen.getByRole('button', { name: /Configure Slot/i }));
await waitFor(() => {
expect(api.configureAmsSlot).toHaveBeenCalled();
});
const payload = (api.configureAmsSlot as ReturnType<typeof vi.fn>).mock.calls[0][3];
expect(payload.cali_idx).toBe(marble.slot_id);
expect(payload.kprofile_filament_id).toBe('GFL99');
});
it('distinguishes two profiles that share a name but differ in K', async () => {
// The picker used to key options by name alone, so same-named profiles
// were indistinguishable and the first always won.
(api.getKProfiles as ReturnType<typeof vi.fn>).mockResolvedValue({
profiles: [
{ ...genericPlaProfiles[0], slot_id: 1, name: 'PLA', k_value: '0.020' },
{ ...genericPlaProfiles[1], slot_id: 2, name: 'PLA', k_value: '0.045' },
],
});
render(
<ConfigureAmsSlotModal
{...defaultProps}
slotInfo={{ ...genericPlaSlot, caliIdx: 0 }}
/>
);
await waitFor(() => {
expect(screen.getByRole('option', { name: /K=0.045/ })).toBeInTheDocument();
});
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'PLA|0.045' } });
fireEvent.click(screen.getByRole('button', { name: /Configure Slot/i }));
await waitFor(() => {
expect(api.configureAmsSlot).toHaveBeenCalled();
});
expect((api.configureAmsSlot as ReturnType<typeof vi.fn>).mock.calls[0][3].cali_idx).toBe(2);
});
});
it('does not include the active K-profile when caliIdx is 0 or null (#1689 guard)', async () => {
// cali_idx == 0 / null means no profile is active (printer default 0.020).
// The safety net only triggers for activeIdx > 0 — otherwise unrelated
@@ -23,6 +23,7 @@ const baseSettings = {
include_beta_updates: false,
obico_enabled: false,
obico_ml_url: '',
obico_ml_token: '',
obico_sensitivity: 'medium',
obico_action: 'notify',
obico_poll_interval: 10,
@@ -83,6 +84,102 @@ describe('FailureDetectionSettings', () => {
expect(await screen.findByText(/ML API reachable/i)).toBeInTheDocument();
});
describe('ML API token (#2733)', () => {
const enabledWithToken = {
...baseSettings,
obico_enabled: true,
obico_ml_url: 'http://obico:3333',
obico_ml_token: 's3cret',
};
it('renders the token as a masked field populated from settings', async () => {
server.use(http.get('/api/v1/settings/', () => HttpResponse.json(enabledWithToken)));
render(<FailureDetectionSettings />);
const input = await screen.findByDisplayValue('s3cret');
expect(input).toHaveAttribute('type', 'password');
expect(screen.getByText(/ML API Token/i)).toBeInTheDocument();
});
it('sends the token with the test-connection request', async () => {
let sent: { url: string; token?: string } | null = null;
server.use(
http.get('/api/v1/settings/', () => HttpResponse.json(enabledWithToken)),
http.post('/api/v1/obico/test-connection', async ({ request }) => {
sent = (await request.json()) as { url: string; token?: string };
return HttpResponse.json({
ok: true,
status_code: 200,
body: 'ok',
error: null,
auth_ok: true,
});
}),
);
render(<FailureDetectionSettings />);
await screen.findByDisplayValue('http://obico:3333');
await userEvent.click(screen.getByRole('button', { name: /test/i }));
await waitFor(() => expect(sent).not.toBeNull());
// The value in the box, not the saved one — so a token can be checked
// before it is committed.
expect(sent!.token).toBe('s3cret');
});
it('reports a rejected token instead of a bare success', async () => {
server.use(
http.get('/api/v1/settings/', () => HttpResponse.json(enabledWithToken)),
http.post('/api/v1/obico/test-connection', () =>
HttpResponse.json({
ok: false,
status_code: 401,
body: 'ok',
error: 'The ML API is reachable but rejected the token.',
auth_ok: false,
}),
),
);
render(<FailureDetectionSettings />);
await screen.findByDisplayValue('http://obico:3333');
await userEvent.click(screen.getByRole('button', { name: /test/i }));
expect(await screen.findByText(/rejected the token/i)).toBeInTheDocument();
});
it('does not claim the token works when it could not be checked', async () => {
server.use(
http.get('/api/v1/settings/', () => HttpResponse.json(enabledWithToken)),
http.post('/api/v1/obico/test-connection', () =>
HttpResponse.json({ ok: true, status_code: 200, body: 'ok', error: null, auth_ok: null }),
),
);
render(<FailureDetectionSettings />);
await screen.findByDisplayValue('http://obico:3333');
await userEvent.click(screen.getByRole('button', { name: /test/i }));
expect(await screen.findByText(/token could not be checked/i)).toBeInTheDocument();
});
it('auto-saves the token', async () => {
let saved: Record<string, unknown> | null = null;
server.use(
http.get('/api/v1/settings/', () => HttpResponse.json({ ...enabledWithToken, obico_ml_token: '' })),
http.put('/api/v1/settings/', async ({ request }) => {
saved = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ ...enabledWithToken, obico_ml_token: 'typed' });
}),
);
render(<FailureDetectionSettings />);
const input = await screen.findByPlaceholderText(/ML_API_TOKEN/i);
// Every field stays disabled until the settings query lands.
await waitFor(() => expect(input).not.toBeDisabled());
await userEvent.type(input, 'typed');
await waitFor(() => expect(saved).not.toBeNull(), { timeout: 3000 });
expect(saved!.obico_ml_token).toBe('typed');
});
});
it('shows failure class history entries with red styling', async () => {
server.use(
http.get('/api/v1/obico/status', () =>
@@ -6,7 +6,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest';
import { screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render } from '../utils';
import { HMSErrorModal } from '../../components/HMSErrorModal';
import { HMSErrorModal, filterKnownHMSErrors } from '../../components/HMSErrorModal';
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';
import type { HMSError } from '../../api/client';
@@ -189,6 +189,50 @@ describe('HMSErrorModal', () => {
});
});
describe('MQTT command verification failed (#2732)', () => {
// attr 0x05000500, code 0x00010007 — a real P1S on firmware 01.10.00.00.
// getShortCode() collapses this to "0500_0007", which matches nothing, so
// before #2732 filterKnownHMSErrors dropped the one error that explained
// why the printer accepted every job and started none of them.
const verifyFailedError: HMSError = {
attr: 0x05000500,
code: '0x10007',
severity: 1,
full_code: '0500050000010007',
};
it('surfaces the error instead of filtering it out', () => {
render(<HMSErrorModal {...defaultProps} errors={[verifyFailedError]} />);
expect(screen.queryByText('No errors')).not.toBeInTheDocument();
expect(screen.getByText(/could not verify it/i)).toBeInTheDocument();
});
it('counts towards the known-error filter', () => {
expect(filterKnownHMSErrors([verifyFailedError])).toHaveLength(1);
});
it('shows the remedy, not just the fault', () => {
render(<HMSErrorModal {...defaultProps} errors={[verifyFailedError]} />);
expect(screen.getByText(/Enable Developer Mode on the printer/i)).toBeInTheDocument();
});
it('displays the code the printer screen shows, not the truncated form', () => {
render(<HMSErrorModal {...defaultProps} errors={[verifyFailedError]} />);
expect(screen.getByText('[0500-0500-0001-0007]')).toBeInTheDocument();
expect(screen.queryByText('[0500-0007]')).not.toBeInTheDocument();
});
it('leaves short-code errors on the two-group display', () => {
render(<HMSErrorModal {...defaultProps} errors={[knownError]} />);
expect(screen.getByText('[0300-400C]')).toBeInTheDocument();
});
it('does not add the remedy line to other errors', () => {
render(<HMSErrorModal {...defaultProps} errors={[knownError]} />);
expect(screen.queryByText(/Enable Developer Mode/i)).not.toBeInTheDocument();
});
});
describe('interactions', () => {
it('calls onClose when X button is clicked', async () => {
const user = userEvent.setup();
@@ -12,6 +12,8 @@ import {
isMatchingCalibration,
toFilamentId,
isGenericFilamentId,
materialForGenericFilamentId,
genericFilamentIdMatchesMaterial,
} from '../../../components/spool-form/utils';
describe('toFilamentId', () => {
@@ -70,6 +72,37 @@ describe('isGenericFilamentId', () => {
});
});
describe('materialForGenericFilamentId (#2710)', () => {
it('resolves the material each generic id stands for', () => {
expect(materialForGenericFilamentId('GFL99')).toBe('PLA');
expect(materialForGenericFilamentId('GFG99')).toBe('PETG');
expect(materialForGenericFilamentId('gfu99')).toBe('TPU');
});
it('returns empty for specific ids and for nothing', () => {
expect(materialForGenericFilamentId('GFL05')).toBe('');
expect(materialForGenericFilamentId(null)).toBe('');
expect(materialForGenericFilamentId('')).toBe('');
});
});
describe('genericFilamentIdMatchesMaterial (#2710)', () => {
it('agrees when the generic id describes that material', () => {
expect(genericFilamentIdMatchesMaterial('GFL99', 'PLA')).toBe(true);
expect(genericFilamentIdMatchesMaterial('GFL99', 'pla')).toBe(true);
});
it('treats Nylon and PA as the same material', () => {
expect(genericFilamentIdMatchesMaterial('GFN99', 'Nylon')).toBe(true);
});
it('rejects a mismatched material, a specific id, or a missing material', () => {
expect(genericFilamentIdMatchesMaterial('GFL99', 'PETG')).toBe(false);
expect(genericFilamentIdMatchesMaterial('GFL05', 'PLA')).toBe(false);
expect(genericFilamentIdMatchesMaterial('GFL99', '')).toBe(false);
});
});
describe('isMatchingCalibration (#1688)', () => {
const formData = {
material: 'PETG',
@@ -98,9 +131,10 @@ describe('isMatchingCalibration (#1688)', () => {
).toBe(true);
});
it('skips id-match for generic GFx99 ids and falls through to name match', () => {
// GFL99 = generic PLA, shared across many real filaments. Even if the
// spool stored GFL99, name parsing must drive the decision.
it('skips id-match when a generic id contradicts the spool material', () => {
// GFL99 is generic *PLA* but the spool says PETG — the ids agreeing is not
// enough, the material has to agree too. Name parsing then drives the
// decision and rejects it.
const result = isMatchingCalibration(
{ name: 'Random thing with no PETG in it', filament_id: 'GFL99' },
{ ...formData, slicer_filament: 'GFL99' },
@@ -108,6 +142,47 @@ describe('isMatchingCalibration (#1688)', () => {
expect(result).toBe(false);
});
it('matches a generic id when the material agrees and the spool claims no brand (#2710)', () => {
// Reporter's printer: every K-profile calibrated under Generic PLA and
// named after the colour. No name parsing can tie "Dark Brown" to PLA, so
// the shared GFL99 is the only signal there is.
expect(
isMatchingCalibration(
{ name: 'Dark Brown', filament_id: 'GFL99' },
{ material: 'PLA', brand: '', subtype: '', slicer_filament: 'GFL99' },
),
).toBe(true);
});
it('treats "Generic" as no brand on the generic id path', () => {
expect(
isMatchingCalibration(
{ name: 'Marble', filament_id: 'GFL99' },
{ material: 'PLA', brand: 'Generic', subtype: '', slicer_filament: 'GFL99' },
),
).toBe(true);
});
it('keeps generic id matches brand-specific when the spool names a brand', () => {
// A spool that says "Sunlu" should still get Sunlu-specific suggestions
// rather than the printer's whole generic-PLA table.
expect(
isMatchingCalibration(
{ name: 'Dark Brown', filament_id: 'GFL99' },
{ material: 'PLA', brand: 'Sunlu', subtype: '', slicer_filament: 'GFL99' },
),
).toBe(false);
});
it('accepts Nylon/PA as the same material on the generic id path', () => {
expect(
isMatchingCalibration(
{ name: 'spool-of-doom', filament_id: 'GFN99' },
{ material: 'Nylon', brand: '', subtype: '', slicer_filament: 'GFN99' },
),
).toBe(true);
});
it('falls through to name match when spool has no slicer_filament', () => {
expect(
isMatchingCalibration(
@@ -110,6 +110,66 @@ describe('SettingsPage', () => {
});
});
describe('finish photo plate restore (#2547)', () => {
const restoreLabel = 'Restore plate for finish photo';
it('offers the toggle while finish photos are enabled', async () => {
render(<SettingsPage />);
expect(await screen.findByText(restoreLabel)).toBeInTheDocument();
});
it('hides the toggle when finish photos are switched off', async () => {
// It only describes how the finish photo is framed, so it is meaningless
// when no finish photo is taken at all.
server.use(
http.get('/api/v1/settings/', () =>
HttpResponse.json({ ...mockSettings, capture_finish_photo: false })
)
);
render(<SettingsPage />);
await screen.findByRole('heading', { name: 'Settings' });
await waitFor(() => {
expect(screen.queryByText(restoreLabel)).not.toBeInTheDocument();
});
});
it('defaults to on when the backend has never stored the setting', async () => {
// Existing installs have no row for it; the UI must not read that as off.
render(<SettingsPage />);
const label = await screen.findByText(restoreLabel);
const row = label.closest('div')!.parentElement!;
expect(within(row).getByRole('checkbox')).toBeChecked();
});
it('sends the new value on save', async () => {
let saved: Record<string, unknown> | null = null;
server.use(
http.put('/api/v1/settings/', async ({ request }) => {
saved = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ ...mockSettings, ...saved });
})
);
render(<SettingsPage />);
const label = await screen.findByText(restoreLabel);
// The page suppresses auto-save for 100ms after the settings load, so a
// click landing inside that window is swallowed with no re-trigger.
await new Promise((resolve) => setTimeout(resolve, 200));
const row = label.closest('div')!.parentElement!;
await userEvent.click(within(row).getByRole('checkbox'));
// The page auto-saves on a 500ms debounce, so the default 1s waitFor
// window is only just wide enough — give the request room to land.
await waitFor(() => {
expect(saved).not.toBeNull();
}, { timeout: 3000 });
expect(saved!.finish_photo_restore_plate).toBe(false);
});
});
describe('general settings', () => {
it('shows date format setting', async () => {
render(<SettingsPage />);
@@ -0,0 +1,75 @@
/**
* Tests for the project-view cache invalidation helper (#2731).
*
* The default staleTime is 60s, so a project page revisited within a minute of
* deleting one of its prints serves the cached answer and keeps showing the
* print. The user had to reload the page by hand. Deletes invalidated only
* `['archives']`; the project-assign mutations only `['projects']`, which
* refreshed the overview cards but never the detail page.
*/
import { describe, it, expect, vi } from 'vitest';
import type { QueryClient } from '@tanstack/react-query';
import { invalidateProjectViews, invalidateArchiveAndProjectViews } from '../../utils/projectQueries';
function mockClient() {
const invalidateQueries = vi.fn().mockResolvedValue(undefined);
return { client: { invalidateQueries } as unknown as QueryClient, invalidateQueries };
}
const keysFrom = (fn: ReturnType<typeof vi.fn>) => fn.mock.calls.map((c) => c[0].queryKey.join('/'));
describe('invalidateProjectViews', () => {
it('refreshes every view whose contents depend on project membership', async () => {
const { client, invalidateQueries } = mockClient();
await invalidateProjectViews(client);
expect(keysFrom(invalidateQueries).sort()).toEqual(
['project', 'project-archives', 'project-file-progress', 'project-timeline', 'projects'].sort(),
);
});
it('uses bare prefixes so every cached project id is covered', async () => {
const { client, invalidateQueries } = mockClient();
await invalidateProjectViews(client);
// ['project'] matches ['project', 42]; ['project', 42] would not match 43.
for (const call of invalidateQueries.mock.calls) {
expect(call[0].queryKey).toHaveLength(1);
}
});
it('does not touch the archive list on its own', async () => {
const { client, invalidateQueries } = mockClient();
await invalidateProjectViews(client);
expect(keysFrom(invalidateQueries)).not.toContain('archives');
});
});
describe('invalidateArchiveAndProjectViews', () => {
it('adds the archive list to the project views', async () => {
const { client, invalidateQueries } = mockClient();
await invalidateArchiveAndProjectViews(client);
const keys = keysFrom(invalidateQueries);
expect(keys).toContain('archives');
expect(keys).toContain('project-archives');
expect(keys).toContain('projects');
});
it('resolves only once every invalidation has settled', async () => {
let settled = 0;
const invalidateQueries = vi.fn().mockImplementation(
() => new Promise<void>((resolve) => setTimeout(() => { settled += 1; resolve(); }, 0)),
);
await invalidateArchiveAndProjectViews({ invalidateQueries } as unknown as QueryClient);
expect(settled).toBe(6);
});
});
+13 -2
View File
@@ -1185,6 +1185,7 @@ export interface AppSettings {
auto_archive: boolean;
save_thumbnails: boolean;
capture_finish_photo: boolean;
finish_photo_restore_plate: boolean;
default_filament_cost: number;
currency: string;
energy_cost_per_kwh: number;
@@ -1277,6 +1278,10 @@ export interface AppSettings {
// Per-install sidecar URLs. Empty string falls back to the env defaults.
orcaslicer_api_url: string;
bambu_studio_api_url: string;
// Minutes of silence from the sidecar before a slice is abandoned. Bounds
// stalls, not total slicing time — a model that keeps reporting progress
// runs to completion however long it takes.
slicer_stall_timeout_minutes: number;
// Prometheus metrics
prometheus_enabled: boolean;
prometheus_token: string;
@@ -1336,6 +1341,7 @@ export interface AppSettings {
ldap_default_group: string;
obico_enabled: boolean;
obico_ml_url: string;
obico_ml_token: string;
obico_sensitivity: 'low' | 'medium' | 'high';
obico_action: 'notify' | 'pause' | 'pause_and_off';
obico_poll_interval: number;
@@ -2783,6 +2789,9 @@ export interface ObicoTestConnection {
status_code: number | null;
body: string | null;
error: string | null;
// Whether the ML API accepted the token. null = not determined (the health
// check failed first, or the token probe itself errored).
auth_ok: boolean | null;
}
export interface GitHubTestConnectionResponse {
@@ -6526,10 +6535,12 @@ export const api = {
getObicoPrinterStatus: () =>
request<ObicoPrinterStatus>('/obico/printer-status'),
testObicoConnection: (url: string) =>
// `token` is sent as-is, so an empty string tests with no token at all.
// Omitting the argument makes the backend fall back to the saved token.
testObicoConnection: (url: string, token?: string) =>
request<ObicoTestConnection>('/obico/test-connection', {
method: 'POST',
body: JSON.stringify({ url }),
body: JSON.stringify(token === undefined ? { url } : { url, token }),
}),
// Slicer API — slice in the background. Both endpoints return 202 + a
@@ -6,6 +6,7 @@ import { api } from '../api/client';
import { Card, CardContent } from './Card';
import { Button } from './Button';
import { useToast } from '../contexts/ToastContext';
import { invalidateArchiveAndProjectViews } from '../utils/projectQueries';
interface BatchProjectModalProps {
selectedIds: number[];
@@ -43,14 +44,9 @@ export function BatchProjectModal({ selectedIds, onClose }: BatchProjectModalPro
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
// Helper to invalidate all project-related queries
const invalidateProjectQueries = () => {
queryClient.invalidateQueries({ queryKey: ['archives'] });
queryClient.invalidateQueries({ queryKey: ['projects'] });
// Invalidate project detail pages (partial match catches all project IDs)
queryClient.invalidateQueries({ queryKey: ['project'] });
queryClient.invalidateQueries({ queryKey: ['project-archives'] });
};
// Helper to invalidate all project-related queries. The shared version also
// covers the timeline and file-progress views, which this list was missing.
const invalidateProjectQueries = () => invalidateArchiveAndProjectViews(queryClient);
// Assign to project mutation (uses bulk API)
const assignMutation = useMutation({
@@ -5,7 +5,7 @@ import { X, Loader2, Settings2, ChevronDown, CheckCircle2, RotateCcw } from 'luc
import { api } from '../api/client';
import type { KProfile } from '../api/client';
import { matchesPrinterModelSuffix, presetCompatibility, buildCompatibilityIndex } from '../utils/slicerPrinterMatch';
import { toFilamentId, isGenericFilamentId } from './spool-form/utils';
import { toFilamentId } from './spool-form/utils';
import { Button } from './Button';
import { getAmsLabel } from '../utils/amsHelpers';
@@ -99,6 +99,13 @@ function parsePresetName(name: string): { material: string; brand: string; varia
return { material: withoutSuffix, brand: '', variant: '' };
}
// Identity of a K-profile inside the picker. Both profile lists are
// deduplicated on name+k_value, so this is unique across the whole option set
// — unlike the bare name, which two profiles can share (#2710).
function kProfileOptionValue(profile: KProfile): string {
return `${profile.name}|${profile.k_value}`;
}
// Check if a preset is a user preset (not built-in)
function isUserPreset(settingId: string): boolean {
// Built-in presets have specific patterns, user presets are UUIDs
@@ -870,7 +877,12 @@ export function ConfigureAmsSlotModal({
const { fullName, material, brand, filamentId } = selectedPresetInfo;
const upperFullName = fullName.toUpperCase();
const upperMaterial = material.toUpperCase();
const upperBrand = brand.toUpperCase();
// "Generic" leads every built-in Bambu preset name ("Generic PLA",
// "Generic PETG") but is not a manufacturer (#2710). Treating it as one
// put the filter into brand-gated mode and demanded "GENERIC" in the
// K-profile name, which no real profile has — so selecting a built-in
// generic preset matched nothing at all.
const upperBrand = brand.toUpperCase() === 'GENERIC' ? '' : brand.toUpperCase();
const presetFid = filamentId; // already normalised via toFilamentId
// Material must be at least 2 chars to avoid false positives
@@ -880,11 +892,18 @@ export function ConfigureAmsSlotModal({
const filtered = kprofilesData.profiles.filter(p => {
// Preferred: exact filament_id match (#1688). A user's custom K-profile
// whose name doesn't agree with the slicer preset still surfaces when
// both sides agree on filament_id. Generic GFx99 IDs are excluded —
// they're shared across many filaments and over-match if id-compared.
// both sides agree on filament_id.
//
// Generic GFx99 ids count here too (#2710). The equality test already
// means both sides carry the *same* id, so the old "generic ids
// over-match" exclusion could only ever fire when the selected preset
// was itself the generic one — precisely the case where the match is
// right. The printer keeps one calibration table per filament_id, so a
// slot on "Generic PLA" should offer every profile calibrated under
// Generic PLA, whatever the user named them.
if (presetFid) {
const calFid = toFilamentId(p.filament_id);
if (calFid && calFid === presetFid && !isGenericFilamentId(calFid)) {
if (calFid && calFid === presetFid) {
return true;
}
}
@@ -966,6 +985,46 @@ export function ConfigureAmsSlotModal({
return result;
}, [kprofilesData?.profiles, selectedPresetInfo, slotInfo.extruderId, slotInfo.caliIdx]);
// Every remaining K-profile the printer holds, offered under a separate group
// after the matching ones (#2710). The matcher works off preset names and
// filament ids, neither of which the user controls when they name a profile
// after its colour — so there is always a residual chance it filters out a
// profile the user wants. This makes that recoverable in the UI instead of
// sending them to the slicer: the printer's own calibration table is the
// authority on what can be selected, and the backend realigns the slot's
// filament context to whichever profile is picked.
const otherKProfiles = useMemo(() => {
if (!kprofilesData?.profiles) return [];
const matched = new Set(matchingKProfiles.map(p => kProfileOptionValue(p)));
// Same name+k_value dedup as the matching list, so a multi-nozzle printer's
// duplicate rows don't show up twice here either.
const seen = new Map<string, KProfile>();
for (const profile of kprofilesData.profiles) {
const key = kProfileOptionValue(profile);
if (matched.has(key)) continue;
const existing = seen.get(key);
if (!existing) {
seen.set(key, profile);
} else if (slotInfo.extruderId !== undefined && profile.extruder_id === slotInfo.extruderId && existing.extruder_id !== slotInfo.extruderId) {
seen.set(key, profile);
}
}
return Array.from(seen.values()).sort((a, b) => a.name.localeCompare(b.name));
}, [kprofilesData?.profiles, matchingKProfiles, slotInfo.extruderId]);
const hasAnyKProfile = matchingKProfiles.length > 0 || otherKProfiles.length > 0;
const selectKProfileByValue = useCallback((value: string) => {
if (!value) {
setSelectedKProfile(null);
return;
}
const profile = matchingKProfiles.find(p => kProfileOptionValue(p) === value)
|| otherKProfiles.find(p => kProfileOptionValue(p) === value)
|| null;
setSelectedKProfile(profile);
}, [matchingKProfiles, otherKProfiles]);
// Pre-select current profile when modal opens, reset when closes
useEffect(() => {
if (isOpen) {
@@ -1234,22 +1293,28 @@ export function ConfigureAmsSlotModal({
</span>
)}
</label>
{matchingKProfiles.length > 0 ? (
{hasAnyKProfile ? (
<div className="relative">
<select
value={selectedKProfile?.name || ''}
onChange={(e) => {
const profile = matchingKProfiles.find(p => p.name === e.target.value);
setSelectedKProfile(profile || null);
}}
value={selectedKProfile ? kProfileOptionValue(selectedKProfile) : ''}
onChange={(e) => selectKProfileByValue(e.target.value)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none pr-10"
>
<option value="">{t('configureAmsSlot.noKProfile')}</option>
{matchingKProfiles.map((profile) => (
<option key={`${profile.name}-${profile.extruder_id}`} value={profile.name}>
<option key={kProfileOptionValue(profile)} value={kProfileOptionValue(profile)}>
{profile.name} (K={profile.k_value})
</option>
))}
{otherKProfiles.length > 0 && (
<optgroup label={t('configureAmsSlot.otherKProfiles')}>
{otherKProfiles.map((profile) => (
<option key={kProfileOptionValue(profile)} value={kProfileOptionValue(profile)}>
{profile.name} (K={profile.k_value})
</option>
))}
</optgroup>
)}
</select>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
</div>
@@ -1473,22 +1538,28 @@ export function ConfigureAmsSlotModal({
</span>
)}
</label>
{matchingKProfiles.length > 0 ? (
{hasAnyKProfile ? (
<div className="relative">
<select
value={selectedKProfile?.name || ''}
onChange={(e) => {
const profile = matchingKProfiles.find(p => p.name === e.target.value);
setSelectedKProfile(profile || null);
}}
value={selectedKProfile ? kProfileOptionValue(selectedKProfile) : ''}
onChange={(e) => selectKProfileByValue(e.target.value)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none pr-10"
>
<option value="">{t('configureAmsSlot.noKProfile')}</option>
{matchingKProfiles.map((profile) => (
<option key={`${profile.name}-${profile.extruder_id}`} value={profile.name}>
<option key={kProfileOptionValue(profile)} value={kProfileOptionValue(profile)}>
{profile.name} (K={profile.k_value})
</option>
))}
{otherKProfiles.length > 0 && (
<optgroup label={t('configureAmsSlot.otherKProfiles')}>
{otherKProfiles.map((profile) => (
<option key={kProfileOptionValue(profile)} value={kProfileOptionValue(profile)}>
{profile.name} (K={profile.k_value})
</option>
))}
</optgroup>
)}
</select>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
</div>
+4 -2
View File
@@ -6,6 +6,7 @@ import { api } from '../api/client';
import type { Archive } from '../api/client';
import { Button } from './Button';
import { PrintLogTable } from './PrintLogTable';
import { invalidateArchiveAndProjectViews } from '../utils/projectQueries';
// Keys for failure reasons - translated at render time.
// Exported so the Print Log per-row classification editor (#1687 part 4)
@@ -147,8 +148,9 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
mutationFn: (data: Parameters<typeof api.updateArchive>[1]) =>
api.updateArchive(archive.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['archives'] });
queryClient.invalidateQueries({ queryKey: ['projects'] });
// This form can change the archive's project, so the project detail
// views need refreshing too — not just the overview cards (#2731).
invalidateArchiveAndProjectViews(queryClient);
onClose();
},
});
@@ -17,6 +17,7 @@ export function FailureDetectionSettings() {
const [enabled, setEnabled] = useState(false);
const [mlUrl, setMlUrl] = useState('');
const [mlToken, setMlToken] = useState('');
const [sensitivity, setSensitivity] = useState<'low' | 'medium' | 'high'>('medium');
const [action, setAction] = useState<'notify' | 'pause' | 'pause_and_off'>('notify');
const [pollInterval, setPollInterval] = useState(10);
@@ -44,6 +45,7 @@ export function FailureDetectionSettings() {
if (!settings) return;
setEnabled(settings.obico_enabled ?? false);
setMlUrl(settings.obico_ml_url ?? '');
setMlToken(settings.obico_ml_token ?? '');
setSensitivity(settings.obico_sensitivity ?? 'medium');
setAction(settings.obico_action ?? 'notify');
setPollInterval(settings.obico_poll_interval ?? 10);
@@ -63,6 +65,7 @@ export function FailureDetectionSettings() {
api.updateSettings({
obico_enabled: enabled,
obico_ml_url: mlUrl,
obico_ml_token: mlToken,
obico_sensitivity: sensitivity,
obico_action: action,
obico_poll_interval: pollInterval,
@@ -84,6 +87,7 @@ export function FailureDetectionSettings() {
const changed =
settings.obico_enabled !== enabled ||
settings.obico_ml_url !== mlUrl ||
(settings.obico_ml_token ?? '') !== mlToken ||
settings.obico_sensitivity !== sensitivity ||
settings.obico_action !== action ||
settings.obico_poll_interval !== pollInterval ||
@@ -92,14 +96,23 @@ export function FailureDetectionSettings() {
const id = setTimeout(() => saveMutation.mutate(), 500);
return () => clearTimeout(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled, mlUrl, sensitivity, action, pollInterval, enabledPrinters, initialized]);
}, [enabled, mlUrl, mlToken, sensitivity, action, pollInterval, enabledPrinters, initialized]);
const handleTest = async () => {
setTestResult(null);
try {
const res = await api.testObicoConnection(mlUrl);
const res = await api.testObicoConnection(mlUrl, mlToken);
if (res.ok) {
setTestResult({ ok: true, message: t('failureDetection.testSuccess') });
// auth_ok is null when the token could not be checked — don't claim it
// works. It is true both for an accepted token and for a server that
// requires none, which is the same outcome for the user.
setTestResult({
ok: true,
message:
res.auth_ok === null
? t('failureDetection.testSuccessTokenUnknown')
: t('failureDetection.testSuccess'),
});
} else {
setTestResult({
ok: false,
@@ -163,6 +176,19 @@ export function FailureDetectionSettings() {
</Button>
</div>
<p className="text-xs text-bambu-gray mt-1">{t('failureDetection.mlUrlHint')}</p>
<label className="block text-sm text-bambu-gray mb-1 mt-3">
{t('failureDetection.mlToken')}
</label>
<input
type="password"
value={mlToken}
onChange={(e) => setMlToken(e.target.value)}
autoComplete="off"
placeholder={t('failureDetection.mlTokenPlaceholder')}
className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white text-sm"
disabled={!enabled}
/>
<p className="text-xs text-bambu-gray mt-1">{t('failureDetection.mlTokenHint')}</p>
{testResult && (
<div
className={`flex items-start gap-2 mt-2 text-sm ${
+43 -5
View File
@@ -33,9 +33,20 @@ const AMS_RUNOUT_SHORT_CODES = new Set([
'0705_8011', '0706_8011', '0707_8011', '07FF_8011',
]);
// Comprehensive error code database (short format: XXXX_YYYY)
// Auto-generated from ha-bambulab - 853 codes
// "MQTT command verification failed" — the firmware's authorization check
// refusing a control command. Keyed by its full 16-char code on purpose: this
// error's meaning lives in attr's low half (0500) and code's high half (0001),
// both of which getShortCode() discards, so the short form is a useless
// "0500_0007". Before #2732 that meant filterKnownHMSErrors dropped it and the
// user was never shown the one message that explained why nothing printed.
export const HMS_MQTT_VERIFY_FAILED = '0500050000010007';
// Comprehensive error code database, keyed by short code (XXXX_YYYY) or, where
// the short code cannot express the error, by full code (16 hex chars).
// Short-code entries auto-generated from ha-bambulab - 853 codes
const ERROR_DESCRIPTIONS: Record<string, string> = {
[HMS_MQTT_VERIFY_FAILED]:
'The printer rejected a command because it could not verify it. Prints, temperature changes and filament loads sent from Bambuddy will be ignored until this is fixed.',
'0300_4000': 'Z axis homing failed; the task has been stopped.',
'0300_4001': 'The printer timed out waiting for the nozzle to cool down before homing.',
'0300_4002': 'Auto Bed Leveling failed; the task has been stopped.',
@@ -913,6 +924,15 @@ function getShortCode(attr: number, code: number): string {
return `${module.toString(16).padStart(4, '0').toUpperCase()}_${codeNum.toString(16).padStart(4, '0').toUpperCase()}`;
}
// Catalog lookup. full_code (16 hex chars for hms[]-sourced faults) is tried
// first because it is lossless; shortCode is the fallback that the bulk of the
// catalog is keyed by. Returns undefined for an uncataloged error so callers can
// tell "no description" from "empty description".
function lookupDescription(fullCode: string | undefined, shortCode: string): string | undefined {
if (fullCode && ERROR_DESCRIPTIONS[fullCode] !== undefined) return ERROR_DESCRIPTIONS[fullCode];
return ERROR_DESCRIPTIONS[shortCode];
}
// Helper to filter HMS errors the UI should surface (exported for use in badge counts).
// Keeps an error if EITHER:
// - it's in the bundled ERROR_DESCRIPTIONS catalog (known, has a description), OR
@@ -925,7 +945,7 @@ export function filterKnownHMSErrors(errors: HMSError[]): HMSError[] {
return errors.filter((error) => {
const codeNum = parseInt(error.code.replace('0x', ''), 16) || 0;
const shortCode = getShortCode(error.attr, codeNum);
if (ERROR_DESCRIPTIONS[shortCode] !== undefined) return true;
if (lookupDescription(error.full_code, shortCode) !== undefined) return true;
return (error.actions?.length ?? 0) > 0;
});
}
@@ -1023,7 +1043,17 @@ export function HMSErrorModal({ printerName, errors, onClose, printerId, hasPerm
// Runout guidance (#2587): for an AMS per-slot runout on a paused
// print, name the slot the firmware now expects rather than the
// misleading generic "insert into the same slot" text.
let description = ERROR_DESCRIPTIONS[shortCode] ?? t('hmsErrors.unknownCode');
const matchedFullCode =
!!error.full_code && ERROR_DESCRIPTIONS[error.full_code] !== undefined;
let description =
lookupDescription(error.full_code, shortCode) ?? t('hmsErrors.unknownCode');
// The remedy is Bambuddy's, not Bambu's — their wiki says "update
// Studio or Handy", which is no help to someone printing from
// Bambuddy. Same override shape as the runout guidance below.
const remedy =
error.full_code === HMS_MQTT_VERIFY_FAILED
? t('hmsErrors.mqttVerifyFailedRemedy')
: null;
if (runoutGuidance && AMS_RUNOUT_SHORT_CODES.has(shortCode)) {
if (runoutGuidance.expectedSlotLabel && runoutGuidance.ranOutSlotLabel) {
description = t('hmsErrors.runoutExpectedSlot', {
@@ -1039,7 +1069,14 @@ export function HMSErrorModal({ printerName, errors, onClose, printerId, hasPerm
}
}
const hmsHomeUrl = getHMSHomeUrl();
const displayCode = shortCode.replace('_', '-');
// Show the printer's own four-group notation when the short code
// could not identify the error — for those, "0500-0007" matches
// nothing the user can look up, while "0500-0500-0001-0007" is
// exactly what the printer screen and the Bambu wiki show.
const displayCode =
matchedFullCode && error.full_code!.length === 16
? error.full_code!.match(/.{4}/g)!.join('-')
: shortCode.replace('_', '-');
return (
<div
@@ -1056,6 +1093,7 @@ export function HMSErrorModal({ printerName, errors, onClose, printerId, hasPerm
</span>
</div>
<p className="text-sm text-bambu-gray mb-2">{description}</p>
{remedy && <p className="text-sm text-bambu-gray mb-2">{remedy}</p>}
{error.actions && error.actions.length > 0 && (
<div className="flex flex-wrap gap-2 my-2">
{error.actions.map((action) => {
+57 -5
View File
@@ -378,13 +378,50 @@ export function toFilamentId(id: string | null | undefined): string {
}
// "GFx99" identifiers (GFL99, GFG99, GFB99, ...) are Bambu's *generic* filament
// IDs — shared across many different physical filaments. Matching K-profiles
// by an exact generic ID would over-match, so the id-match path skips them and
// the caller falls through to name-based matching.
// IDs — one per material, shared across every physical filament the user hasn't
// given a specific preset. They still identify a material unambiguously, so an
// exact generic id match is only ambiguous about *brand*, never about material.
export function isGenericFilamentId(id: string | null | undefined): boolean {
return !!id && /^GF[A-Z]99$/i.test(id);
}
// The material each generic Bambu filament ID stands for. Used to sanity-check
// a generic id-match against the material the caller already knows (#2710): a
// PETG spool must never claim GFL99 (generic PLA) profiles just because both
// sides happen to have stored the same generic id.
const GENERIC_FILAMENT_MATERIALS: Record<string, string> = {
GFB99: 'ABS',
GFC99: 'PC',
GFG99: 'PETG',
GFL99: 'PLA',
GFN99: 'PA',
GFP99: 'PE',
GFR99: 'EVA',
GFS99: 'PVA',
GFU99: 'TPU',
};
// Material a generic filament ID stands for ("GFL99" → "PLA"), or '' when the
// ID isn't a known generic one.
export function materialForGenericFilamentId(id: string | null | undefined): string {
if (!id) return '';
return GENERIC_FILAMENT_MATERIALS[id.toUpperCase()] || '';
}
// Bambu labels nylon "PA"; users routinely type "Nylon". Compare materials
// through this so the two spellings agree.
function normaliseMaterial(material: string): string {
const upper = material.trim().toUpperCase();
return upper === 'NYLON' ? 'PA' : upper;
}
// True when a generic filament ID may stand in for the given material — i.e.
// the ID is generic and describes that same material.
export function genericFilamentIdMatchesMaterial(id: string, material: string): boolean {
const generic = materialForGenericFilamentId(id);
return !!generic && !!material && normaliseMaterial(generic) === normaliseMaterial(material);
}
// Check if a calibration matches based on brand, material, and variant
export function isMatchingCalibration(
cal: { name?: string; filament_id?: string },
@@ -399,8 +436,23 @@ export function isMatchingCalibration(
// "GFG98" without going anywhere near parsePresetName.
const spoolFid = toFilamentId(formData.slicer_filament);
const calFid = toFilamentId(cal.filament_id);
if (spoolFid && calFid && spoolFid === calFid && !isGenericFilamentId(calFid)) {
return true;
if (spoolFid && calFid && spoolFid === calFid) {
if (!isGenericFilamentId(calFid)) {
return true;
}
// Both sides carry the same *generic* id (#2710). That still pins the
// material, so the only thing left ambiguous is brand — a printer holds
// one flat calibration table per generic id and users routinely name
// those entries by colour ("Dark Brown", "Marble"), which no amount of
// name parsing can tie back to a material. Accept the match when the
// material agrees and the spool claims no brand of its own; a spool that
// does name a brand keeps the stricter name-based path below so its
// suggestions stay brand-specific.
const brand = formData.brand.trim();
const brandIsGeneric = !brand || brand.toUpperCase() === 'GENERIC';
if (brandIsGeneric && genericFilamentIdMatchesMaterial(calFid, formData.material)) {
return true;
}
}
const profileName = cal.name || '';
+10
View File
@@ -2237,6 +2237,8 @@ export default {
slicerCard: 'Slicer',
orcaslicerApiUrl: 'OrcaSlicer Sidecar-URL',
bambuStudioApiUrl: 'Bambu Studio Sidecar-URL',
slicerStallTimeout: 'Zeitlimit bei Slicer-Stillstand (Minuten)',
slicerStallTimeoutDescription: 'Bricht einen Slice-Vorgang ab, wenn der Sidecar so lange keinen Fortschritt meldet. Aufwendige Modelle, die weiter Fortschritt melden, werden nie abgebrochen, egal wie lange sie brauchen. Sidecars ohne Fortschrittsmeldung nutzen diesen Wert stattdessen als Gesamtzeitlimit.',
slicerApiUrlDescription: 'URL des Slicer-API-Sidecar-Containers. Leer lassen, um die SLICER_API_URL- bzw. BAMBU_STUDIO_API_URL-Umgebungsvariablen zu nutzen.',
slicerBundlesRemoved: {
title: 'Slicer-Bundles (entfernt)',
@@ -2452,6 +2454,8 @@ export default {
autoArchiveDescription: '3MF-Dateien automatisch speichern, wenn Drucke abgeschlossen sind',
saveThumbnailsDescription: 'Vorschaubilder aus 3MF-Dateien extrahieren und speichern',
captureFinishPhotoDescription: 'Foto von der Druckerkamera aufnehmen, wenn der Druck abgeschlossen ist. Bambuddy zeichnet während des Drucks einen kurzen Zeitraffer auf, damit das Foto aus dem Moment vor dem Absenken der Druckplatte stammen kann. Die Zeitraffer-Datei bleibt erhalten, wenn du den Zeitraffer für diesen Druck aktiviert hast, andernfalls wird sie nach Aufnahme des Fotos automatisch gelöscht.',
finishPhotoRestorePlate: 'Druckplatte für Abschlussfoto anheben',
finishPhotoRestorePlateDescription: 'Der Drucker senkt die Druckplatte am Druckende um etwa 100 mm ab, wodurch der fertige Druck unterhalb des Kamerabildausschnitts liegt. Bambuddy hebt sie wieder bis knapp über die zuletzt gedruckte Schicht an, nimmt das Foto auf und senkt sie danach wieder ab. Wird übersprungen, wenn die Druckhöhe unbekannt ist oder ein weiterer Auftrag in der Warteschlange steht.',
ffmpegNotInstalled: 'ffmpeg nicht installiert',
ffmpegRequired: 'Kameraaufnahme benötigt ffmpeg. Installieren über <brew>brew install ffmpeg</brew> (macOS) oder <apt>apt install ffmpeg</apt> (Linux).',
// Camera
@@ -2841,6 +2845,7 @@ export default {
title: 'Fehler - {{name}}',
noErrors: 'Keine Fehler',
viewOnWiki: 'Im Bambu Lab Wiki ansehen',
mqttVerifyFailedRemedy: 'Aktiviere den Entwicklermodus am Drucker, starte den Drucker neu und starte den Auftrag dann erneut.',
unknownCode: 'Unbekannter HMS-Code — Details siehe Bambu Lab Wiki.',
clearInstructions: 'Löschen Sie die Fehler am Drucker, um sie hier zu entfernen.',
clearErrors: 'Fehler löschen',
@@ -5933,6 +5938,7 @@ export default {
filteringFor: 'Filtern nach: {{material}}',
noKProfile: 'Kein K-Profil (Standard 0.020 verwenden)',
noMatchingKProfiles: 'Keine passenden K-Profile gefunden. Standard K=0.020 wird verwendet.',
otherKProfiles: 'Weitere K-Profile auf diesem Drucker',
selectFilamentFirst: 'Zuerst ein Filamentprofil auswählen',
kFromCalibration: 'K={{value}} aus Druckerkalibrierung',
customColorLabel: 'Benutzerdefinierte Farbe (optional)',
@@ -6541,8 +6547,12 @@ export default {
description: 'Überwacht Drucke über eine selbst gehostete Obico-ML-API und reagiert automatisch auf erkannte Fehldrucke.',
mlUrl: 'Obico-ML-API-URL',
mlUrlHint: 'Basis-URL deines selbst gehosteten Obico-ml_api-Containers (z. B. http://192.168.1.10:3333).',
mlToken: 'ML-API-Token (optional)',
mlTokenPlaceholder: 'Leer lassen, wenn der Server ohne ML_API_TOKEN läuft',
mlTokenHint: 'Muss mit der Umgebungsvariable ML_API_TOKEN deines Obico-ml_api-Containers übereinstimmen. Leer lassen, wenn der Container ohne Token läuft.',
test: 'Testen',
testSuccess: 'ML-API erreichbar und funktionsfähig.',
testSuccessTokenUnknown: 'ML-API erreichbar und funktionsfähig. Das Token konnte nicht geprüft werden.',
testFailed: 'ML-API konnte nicht erreicht werden.',
sensitivity: 'Empfindlichkeit',
sensitivityLow: 'Niedrig (weniger Fehlalarme)',
+10
View File
@@ -2256,6 +2256,8 @@ export default {
slicerCard: 'Slicer',
orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
bambuStudioApiUrl: 'Bambu Studio sidecar URL',
slicerStallTimeout: 'Slicer stall timeout (minutes)',
slicerStallTimeoutDescription: 'Give up on a slice after this long with no progress from the sidecar. Heavy models that keep reporting progress are never cut off, however long they take. Sidecars that do not report progress use this as a total time limit instead.',
slicerApiUrlDescription: 'URL of the slicer-API sidecar container. Leave blank to use the SLICER_API_URL / BAMBU_STUDIO_API_URL env var defaults.',
slicerBundlesRemoved: {
title: 'Slicer Bundles (removed)',
@@ -2471,6 +2473,8 @@ export default {
autoArchiveDescription: 'Automatically save 3MF files when prints complete',
saveThumbnailsDescription: 'Extract and save preview images from 3MF files',
captureFinishPhotoDescription: 'Take a photo from printer camera when print completes. Bambuddy records a brief timelapse during the print so the photo can be sourced from the moment before the bed drops; the timelapse file is kept if you enabled timelapse for this print, otherwise it is deleted automatically after the photo is captured.',
finishPhotoRestorePlate: 'Restore plate for finish photo',
finishPhotoRestorePlateDescription: 'The printer drops the build plate about 100 mm when a print ends, leaving the finished print below the camera\'s framing. Bambuddy raises it back to just above the last printed layer, takes the photo, then lowers it again. Skipped when the print height is unknown or another job is queued.',
ffmpegNotInstalled: 'ffmpeg not installed',
ffmpegRequired: 'Camera capture requires ffmpeg. Install it via <brew>brew install ffmpeg</brew> (macOS) or <apt>apt install ffmpeg</apt> (Linux).',
// Camera
@@ -2870,6 +2874,7 @@ export default {
title: 'Errors - {{name}}',
noErrors: 'No errors',
viewOnWiki: 'View on Bambu Lab Wiki',
mqttVerifyFailedRemedy: 'Enable Developer Mode on the printer, restart the printer, then start the job again.',
unknownCode: 'Unknown HMS code — see the Bambu Lab wiki for details.',
clearInstructions: 'Clear errors on the printer to dismiss them here.',
clearErrors: 'Clear Errors',
@@ -5977,6 +5982,7 @@ export default {
filteringFor: 'Filtering for: {{material}}',
noKProfile: 'No K profile (use default 0.020)',
noMatchingKProfiles: 'No matching K profiles found. Default K=0.020 will be used.',
otherKProfiles: 'Other K profiles on this printer',
selectFilamentFirst: 'Select a filament profile first',
kFromCalibration: 'K={{value}} from printer calibration',
customColorLabel: 'Custom Color (optional)',
@@ -6585,8 +6591,12 @@ export default {
description: 'Monitor prints with a self-hosted Obico ML API and act on detected failures automatically.',
mlUrl: 'Obico ML API URL',
mlUrlHint: 'Base URL of your self-hosted Obico ml_api container (e.g. http://192.168.1.10:3333).',
mlToken: 'ML API Token (optional)',
mlTokenPlaceholder: 'Leave empty if the server runs without ML_API_TOKEN',
mlTokenHint: 'Must match the ML_API_TOKEN environment variable of your Obico ml_api container. Leave empty when the container runs without one.',
test: 'Test',
testSuccess: 'ML API reachable and healthy.',
testSuccessTokenUnknown: 'ML API reachable and healthy. The token could not be checked.',
testFailed: 'Could not reach the ML API.',
sensitivity: 'Sensitivity',
sensitivityLow: 'Low (fewer false positives)',
+10
View File
@@ -2240,6 +2240,8 @@ export default {
slicerCard: 'Laminador',
orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
bambuStudioApiUrl: 'Bambu Studio sidecar URL',
slicerStallTimeout: 'Tiempo de espera por inactividad del laminador (minutos)',
slicerStallTimeoutDescription: 'Abandona un laminado tras este tiempo sin progreso del sidecar. Los modelos pesados que siguen informando progreso nunca se interrumpen, por mucho que tarden. Los sidecars que no informan progreso usan este valor como limite de tiempo total.',
slicerApiUrlDescription: 'URL del contenedor auxiliar de la API del laminador. Déjelo en blanco para usar los valores predeterminados de las variables de entorno SLICER_API_URL / BAMBU_STUDIO_API_URL.',
slicerBundlesRemoved: {
title: 'Paquetes del laminador (eliminado)',
@@ -2455,6 +2457,8 @@ export default {
autoArchiveDescription: 'Guardar automáticamente los archivos 3MF cuando se completan las impresiones',
saveThumbnailsDescription: 'Extraer y guardar imágenes de vista previa de los archivos 3MF',
captureFinishPhotoDescription: 'Tomar una foto desde la cámara de la impresora cuando se completa la impresión. Bambuddy graba un breve timelapse durante la impresión para que la foto pueda obtenerse del momento previo al descenso de la cama; el archivo del timelapse se conserva si activaste el timelapse para esta impresión, de lo contrario se elimina automáticamente tras capturar la foto.',
finishPhotoRestorePlate: 'Elevar la cama para la foto final',
finishPhotoRestorePlateDescription: 'La impresora baja la cama unos 100 mm al terminar una impresión, dejando la pieza terminada por debajo del encuadre de la cámara. Bambuddy la vuelve a subir hasta justo encima de la última capa impresa, toma la foto y luego la baja de nuevo. Se omite si se desconoce la altura de la impresión o si hay otro trabajo en cola.',
ffmpegNotInstalled: 'ffmpeg no instalado',
ffmpegRequired: 'La captura de cámara requiere ffmpeg. Instálelo mediante <brew>brew install ffmpeg</brew> (macOS) o <apt>apt install ffmpeg</apt> (Linux).',
// Camera
@@ -2844,6 +2848,7 @@ export default {
title: 'Errores - {{name}}',
noErrors: 'No hay errores',
viewOnWiki: 'Ver en la wiki de Bambu Lab',
mqttVerifyFailedRemedy: 'Activa el modo desarrollador en la impresora, reinicia la impresora y vuelve a iniciar el trabajo.',
unknownCode: 'Código HMS desconocido — consulta la wiki de Bambu Lab para más detalles.',
clearInstructions: 'Borre los errores en la impresora para descartarlos aquí.',
clearErrors: 'Borrar errores',
@@ -5942,6 +5947,7 @@ export default {
filteringFor: 'Filtrando por: {{material}}',
noKProfile: 'Sin perfil K (usar el predeterminado 0,020)',
noMatchingKProfiles: 'No se encontraron perfiles K coincidentes. Se usará el K=0,020 predeterminado.',
otherKProfiles: 'Otros perfiles K en esta impresora',
selectFilamentFirst: 'Seleccione primero un perfil de filamento',
kFromCalibration: 'K={{value}} de la calibración de la impresora',
customColorLabel: 'Color personalizado (opcional)',
@@ -6550,8 +6556,12 @@ export default {
description: 'Supervise las impresiones con una API de ML de Obico autoalojada y actúe automáticamente ante los fallos detectados.',
mlUrl: 'URL de la API de ML de Obico',
mlUrlHint: 'URL base de su contenedor ml_api de Obico autoalojado (p. ej. http://192.168.1.10:3333).',
mlToken: 'Token de la API de ML (opcional)',
mlTokenPlaceholder: 'Déjelo vacío si el servidor funciona sin ML_API_TOKEN',
mlTokenHint: 'Debe coincidir con la variable de entorno ML_API_TOKEN de su contenedor ml_api de Obico. Déjelo vacío si el contenedor funciona sin token.',
test: 'Probar',
testSuccess: 'API de ML accesible y correcta.',
testSuccessTokenUnknown: 'API de ML accesible y correcta. No se pudo comprobar el token.',
testFailed: 'No se pudo alcanzar la API de ML.',
sensitivity: 'Sensibilidad',
sensitivityLow: 'Baja (menos falsos positivos)',
+10
View File
@@ -2193,6 +2193,8 @@ export default {
slicerCard: 'Slicer',
orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
bambuStudioApiUrl: 'Bambu Studio sidecar URL',
slicerStallTimeout: "Delai d'inactivite du trancheur (minutes)",
slicerStallTimeoutDescription: 'Abandonne un decoupage apres cette duree sans progression du sidecar. Les modeles lourds qui continuent a signaler leur progression ne sont jamais interrompus, quel que soit le temps necessaire. Les sidecars qui ne signalent pas de progression utilisent cette valeur comme limite de duree totale.',
slicerApiUrlDescription: 'URL du conteneur sidecar slicer-API. Laisser vide pour utiliser les variables d\'environnement SLICER_API_URL / BAMBU_STUDIO_API_URL.',
slicerBundlesRemoved: {
title: 'Bundles de slicer (supprimé)',
@@ -2406,6 +2408,8 @@ export default {
autoArchiveDescription: 'Sauvegarder automatiquement les fichiers 3MF à la fin des impressions',
saveThumbnailsDescription: 'Extraire et sauvegarder les images d\'aperçu des fichiers 3MF',
captureFinishPhotoDescription: 'Prendre une photo avec la caméra de l\'imprimante à la fin de l\'impression. Bambuddy enregistre un court timelapse pendant l\'impression afin que la photo puisse provenir du moment précédant l\'abaissement du plateau ; le fichier du timelapse est conservé si vous avez activé le timelapse pour cette impression, sinon il est supprimé automatiquement après la capture de la photo.',
finishPhotoRestorePlate: 'Remonter le plateau pour la photo finale',
finishPhotoRestorePlateDescription: 'L\'imprimante abaisse le plateau d\'environ 100 mm à la fin d\'une impression, plaçant l\'objet terminé sous le cadrage de la caméra. Bambuddy le remonte juste au-dessus de la dernière couche imprimée, prend la photo, puis le rabaisse. Ignoré si la hauteur d\'impression est inconnue ou si un autre travail est en file d\'attente.',
ffmpegNotInstalled: 'ffmpeg non installé',
ffmpegRequired: 'La capture caméra nécessite ffmpeg. Installez-le via <brew>brew install ffmpeg</brew> (macOS) ou <apt>apt install ffmpeg</apt> (Linux).',
camera: 'Caméra',
@@ -2830,6 +2834,7 @@ export default {
title: 'Erreurs - {{name}}',
noErrors: 'Aucune erreur',
viewOnWiki: 'Voir sur le Wiki Bambu Lab',
mqttVerifyFailedRemedy: "Activez le mode developpeur sur l'imprimante, redemarrez l'imprimante, puis relancez la tache.",
unknownCode: 'Code HMS inconnu — consultez le wiki Bambu Lab pour plus de détails.',
clearInstructions: 'Effacez les erreurs sur l\'imprimante pour les retirer ici.',
clearErrors: 'Effacer les erreurs',
@@ -5923,6 +5928,7 @@ export default {
filteringFor: 'Filtrage pour : {{material}}',
noKProfile: 'Pas de profil K (utiliser défaut 0.020)',
noMatchingKProfiles: 'Aucun profil K trouvé. K=0.020 par défaut sera utilisé.',
otherKProfiles: 'Autres profils K sur cette imprimante',
selectFilamentFirst: 'Sélectionnez d\'abord un profil filament',
kFromCalibration: 'K={{value}} de la calibration imprimante',
customColorLabel: 'Couleur personnalisée (optionnel)',
@@ -6531,8 +6537,12 @@ export default {
description: 'Surveille les impressions via une API ML Obico auto-hébergée et agit automatiquement sur les échecs détectés.',
mlUrl: 'URL de l\'API ML Obico',
mlUrlHint: 'URL de base de votre conteneur Obico ml_api auto-hébergé (ex. http://192.168.1.10:3333).',
mlToken: 'Jeton de l\'API ML (facultatif)',
mlTokenPlaceholder: 'Laissez vide si le serveur fonctionne sans ML_API_TOKEN',
mlTokenHint: 'Doit correspondre à la variable d\'environnement ML_API_TOKEN de votre conteneur Obico ml_api. Laissez vide si le conteneur fonctionne sans jeton.',
test: 'Tester',
testSuccess: 'API ML accessible et fonctionnelle.',
testSuccessTokenUnknown: 'API ML accessible et fonctionnelle. Le jeton n\'a pas pu être vérifié.',
testFailed: 'Impossible d\'atteindre l\'API ML.',
sensitivity: 'Sensibilité',
sensitivityLow: 'Basse (moins de faux positifs)',
+10
View File
@@ -2193,6 +2193,8 @@ export default {
slicerCard: 'Slicer',
orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
bambuStudioApiUrl: 'Bambu Studio sidecar URL',
slicerStallTimeout: 'Timeout di inattivita dello slicer (minuti)',
slicerStallTimeoutDescription: 'Interrompe uno slice dopo questo tempo senza progressi dal sidecar. I modelli pesanti che continuano a segnalare progressi non vengono mai interrotti, per quanto tempo richiedano. I sidecar che non segnalano progressi usano questo valore come limite di tempo totale.',
slicerApiUrlDescription: 'URL del container sidecar slicer-API. Lascia vuoto per usare le variabili d\'ambiente SLICER_API_URL / BAMBU_STUDIO_API_URL.',
slicerBundlesRemoved: {
title: 'Bundle slicer (rimosso)',
@@ -2405,6 +2407,8 @@ export default {
autoArchiveDescription: 'Salva automaticamente i file 3MF al completamento delle stampe',
saveThumbnailsDescription: 'Estrai e salva le immagini di anteprima dai file 3MF',
captureFinishPhotoDescription: 'Scatta una foto dalla fotocamera della stampante al completamento della stampa. Bambuddy registra un breve timelapse durante la stampa in modo che la foto possa essere ricavata dal momento precedente all\'abbassamento del piatto; il file del timelapse viene mantenuto se hai abilitato il timelapse per questa stampa, altrimenti viene eliminato automaticamente dopo l\'acquisizione della foto.',
finishPhotoRestorePlate: 'Solleva il piatto per la foto finale',
finishPhotoRestorePlateDescription: 'La stampante abbassa il piatto di circa 100 mm al termine di una stampa, lasciando l\'oggetto finito sotto l\'inquadratura della fotocamera. Bambuddy lo risolleva fino a poco sopra l\'ultimo strato stampato, scatta la foto e poi lo riabbassa. Ignorato se l\'altezza di stampa è sconosciuta o se un altro lavoro è in coda.',
ffmpegNotInstalled: 'ffmpeg non installato',
ffmpegRequired: 'L\'acquisizione dalla fotocamera richiede ffmpeg. Installalo tramite <brew>brew install ffmpeg</brew> (macOS) o <apt>apt install ffmpeg</apt> (Linux).',
camera: 'Fotocamera',
@@ -2829,6 +2833,7 @@ export default {
title: 'Errori - {{name}}',
noErrors: 'Nessun errore',
viewOnWiki: 'Vedi su Bambu Lab Wiki',
mqttVerifyFailedRemedy: 'Attiva la modalita sviluppatore sulla stampante, riavvia la stampante e avvia di nuovo il lavoro.',
unknownCode: 'Codice HMS sconosciuto — consulta la wiki di Bambu Lab per i dettagli.',
clearInstructions: 'Cancella gli errori sulla stampante per rimuoverli qui.',
clearErrors: 'Cancella errori',
@@ -5922,6 +5927,7 @@ export default {
filteringFor: 'Filtrando per: {{material}}',
noKProfile: 'Nessun profilo K (usa predefinito 0.020)',
noMatchingKProfiles: 'Nessun profilo K corrispondente. Verrà usato K=0.020 predefinito.',
otherKProfiles: 'Altri profili K su questa stampante',
selectFilamentFirst: 'Seleziona prima un profilo filamento',
kFromCalibration: 'K={{value}} dalla calibrazione stampante',
customColorLabel: 'Colore personalizzato (opzionale)',
@@ -6530,8 +6536,12 @@ export default {
description: 'Monitora le stampe tramite un\'API ML Obico auto-ospitata e agisce automaticamente sui guasti rilevati.',
mlUrl: 'URL API ML Obico',
mlUrlHint: 'URL base del tuo container Obico ml_api auto-ospitato (es. http://192.168.1.10:3333).',
mlToken: 'Token API ML (facoltativo)',
mlTokenPlaceholder: 'Lascia vuoto se il server funziona senza ML_API_TOKEN',
mlTokenHint: 'Deve corrispondere alla variabile di ambiente ML_API_TOKEN del tuo container Obico ml_api. Lascia vuoto se il container funziona senza token.',
test: 'Prova',
testSuccess: 'API ML raggiungibile e funzionante.',
testSuccessTokenUnknown: 'API ML raggiungibile e funzionante. Non è stato possibile verificare il token.',
testFailed: 'Impossibile raggiungere l\'API ML.',
sensitivity: 'Sensibilità',
sensitivityLow: 'Bassa (meno falsi positivi)',
+10
View File
@@ -2236,6 +2236,8 @@ export default {
slicerCard: 'スライサー',
orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
bambuStudioApiUrl: 'Bambu Studio sidecar URL',
slicerStallTimeout: 'スライサー停止タイムアウト(分)',
slicerStallTimeoutDescription: 'サイドカーからの進捗がこの時間なければスライスを中止します。進捗を報告し続ける重いモデルは、どれだけ時間がかかっても中断されません。進捗を報告しないサイドカーでは、この値が合計時間の上限になります。',
slicerApiUrlDescription: 'slicer-APIサイドカーコンテナのURL。空のままにすると SLICER_API_URL / BAMBU_STUDIO_API_URL 環境変数のデフォルト値が使用されます。',
slicerBundlesRemoved: {
title: 'スライサーバンドル(削除済み)',
@@ -2451,6 +2453,8 @@ export default {
autoArchiveDescription: '印刷完了時に3MFファイルを自動保存',
saveThumbnailsDescription: '3MFファイルからプレビュー画像を抽出して保存',
captureFinishPhotoDescription: '印刷完了時にプリンターカメラから写真を撮影します。Bambuddy は印刷中に短いタイムラプスを記録し、ベッドが下がる前の瞬間から写真を取得できるようにします。この印刷でタイムラプスを有効にしていた場合はタイムラプスファイルが保存され、それ以外の場合は写真の取得後に自動的に削除されます。',
finishPhotoRestorePlate: '完了写真のためにプレートを戻す',
finishPhotoRestorePlateDescription: 'プリンターは印刷終了時にビルドプレートを約 100 mm 下降させるため、完成した造形物がカメラの画角より下に来ます。Bambuddy はプレートを最終印刷レイヤーのすぐ上まで戻して写真を撮影し、その後再び下降させます。造形高さが不明な場合や次のジョブがキューにある場合はスキップされます。',
ffmpegNotInstalled: 'ffmpegがインストールされていません',
ffmpegRequired: 'カメラ撮影にはffmpegが必要です。<brew>brew install ffmpeg</brew>(macOS)または<apt>apt install ffmpeg</apt>(Linux)でインストールしてください。',
// Camera
@@ -2841,6 +2845,7 @@ export default {
title: 'エラー - {{name}}',
noErrors: 'エラーなし',
viewOnWiki: 'Bambu Lab Wikiで表示',
mqttVerifyFailedRemedy: 'プリンターで開発者モードを有効にし、プリンターを再起動してから、ジョブをもう一度開始してください。',
unknownCode: '不明なHMSコード — 詳細はBambu Lab Wikiを参照してください。',
clearInstructions: 'プリンターでエラーをクリアするとここからも消えます。',
clearErrors: 'エラーをクリア',
@@ -5934,6 +5939,7 @@ export default {
filteringFor: 'フィルター中: {{material}}',
noKProfile: 'Kプロファイルなし(デフォルト0.020を使用)',
noMatchingKProfiles: '一致するKプロファイルが見つかりません。デフォルトK=0.020が使用されます。',
otherKProfiles: 'このプリンターの他のKプロファイル',
selectFilamentFirst: 'まずフィラメントプロファイルを選択してください',
kFromCalibration: 'K={{value}}(プリンターキャリブレーションから)',
customColorLabel: 'カスタム色(オプション)',
@@ -6542,8 +6548,12 @@ export default {
description: 'セルフホストされた Obico ML API で印刷を監視し、検出された失敗に自動的に対応します。',
mlUrl: 'Obico ML API の URL',
mlUrlHint: 'セルフホストした Obico ml_api コンテナのベース URL (例: http://192.168.1.10:3333)。',
mlToken: 'ML API トークン(任意)',
mlTokenPlaceholder: 'サーバーが ML_API_TOKEN なしで動作している場合は空のままにします',
mlTokenHint: 'Obico ml_api コンテナの環境変数 ML_API_TOKEN と一致させる必要があります。コンテナがトークンなしで動作している場合は空のままにしてください。',
test: 'テスト',
testSuccess: 'ML API に接続でき、正常です。',
testSuccessTokenUnknown: 'ML API に接続でき、正常です。トークンは確認できませんでした。',
testFailed: 'ML API に接続できませんでした。',
sensitivity: '感度',
sensitivityLow: '低(誤検出が少ない)',
+10
View File
@@ -2110,6 +2110,8 @@ export default {
slicerCard: '슬라이서',
orcaslicerApiUrl: 'OrcaSlicer 사이드카 URL',
bambuStudioApiUrl: 'Bambu Studio 사이드카 URL',
slicerStallTimeout: '슬라이서 정지 시간 제한(분)',
slicerStallTimeoutDescription: '사이드카에서 이 시간 동안 진행 상황이 없으면 슬라이싱을 중단합니다. 진행 상황을 계속 보고하는 무거운 모델은 아무리 오래 걸려도 중단되지 않습니다. 진행 상황을 보고하지 않는 사이드카에서는 이 값이 전체 시간 제한으로 사용됩니다.',
slicerApiUrlDescription: '슬라이서 API 사이드카 컨테이너의 URL. SLICER_API_URL / BAMBU_STUDIO_API_URL 환경 변수 기본값을 사용하려면 비워두세요.',
slicerBundlesRemoved: {
title: '슬라이서 번들 (제거됨)',
@@ -2321,6 +2323,8 @@ export default {
autoArchiveDescription: '인쇄 완료 시 3MF 파일 자동 저장',
saveThumbnailsDescription: '3MF 파일에서 미리보기 이미지 추출 및 저장',
captureFinishPhotoDescription: '인쇄 완료 시 프린터 카메라로 사진 촬영. Bambuddy는 인쇄 중 짧은 타임랩스를 기록하여 베드가 내려가기 전 순간에서 사진을 가져올 수 있도록 합니다. 이 인쇄에 대해 타임랩스를 활성화한 경우 타임랩스 파일이 보관되며, 그렇지 않으면 사진 촬영 후 자동으로 삭제됩니다.',
finishPhotoRestorePlate: '완료 사진을 위해 베드 올리기',
finishPhotoRestorePlateDescription: '프린터는 인쇄가 끝나면 베드를 약 100 mm 내리므로 완성된 출력물이 카메라 화각 아래에 놓입니다. Bambuddy는 베드를 마지막 인쇄 레이어 바로 위까지 다시 올려 사진을 찍은 뒤 다시 내립니다. 출력 높이를 알 수 없거나 다른 작업이 대기 중이면 건너뜁니다.',
ffmpegNotInstalled: 'ffmpeg 미설치',
ffmpegRequired: '카메라 캡처에 ffmpeg가 필요합니다. macOS에서는 <brew>brew install ffmpeg</brew>, Linux에서는 <apt>apt install ffmpeg</apt>로 설치하세요.',
camera: '카메라',
@@ -2691,6 +2695,7 @@ export default {
title: '오류 - {{name}}',
noErrors: '오류 없음',
viewOnWiki: 'Bambu Lab 위키에서 보기',
mqttVerifyFailedRemedy: '프린터에서 개발자 모드를 활성화하고 프린터를 재시작한 다음 작업을 다시 시작하세요.',
unknownCode: '알 수 없는 HMS 코드 — 자세한 내용은 Bambu Lab 위키를 참조하세요.',
clearInstructions: '오류를 해제하려면 프린터에서 오류를 지우세요.',
clearErrors: '오류 지우기',
@@ -5624,6 +5629,7 @@ export default {
filteringFor: '필터링 중: {{material}}',
noKProfile: 'K 프로필 없음 (기본값 0.020 사용)',
noMatchingKProfiles: '일치하는 K 프로필을 찾을 수 없습니다. 기본값 K=0.020이 사용됩니다.',
otherKProfiles: '이 프린터의 다른 K 프로필',
selectFilamentFirst: '먼저 필라멘트 프로필을 선택하세요',
kFromCalibration: 'K={{value}} (프린터 보정에서)',
customColorLabel: '사용자 지정 색상 (선택사항)',
@@ -6010,8 +6016,12 @@ export default {
description: '자체 호스팅 Obico ML API로 인쇄를 모니터링하고 감지된 실패에 자동으로 조치합니다.',
mlUrl: 'Obico ML API URL',
mlUrlHint: '자체 호스팅 Obico ml_api 컨테이너의 기본 URL (예: http://192.168.1.10:3333).',
mlToken: 'ML API 토큰 (선택 사항)',
mlTokenPlaceholder: '서버가 ML_API_TOKEN 없이 실행 중이면 비워 두세요',
mlTokenHint: 'Obico ml_api 컨테이너의 ML_API_TOKEN 환경 변수와 일치해야 합니다. 컨테이너가 토큰 없이 실행 중이면 비워 두세요.',
test: '테스트',
testSuccess: 'ML API에 도달 가능하며 정상입니다.',
testSuccessTokenUnknown: 'ML API에 도달 가능하며 정상입니다. 토큰은 확인할 수 없었습니다.',
testFailed: 'ML API에 도달할 수 없습니다.',
sensitivity: '민감도',
sensitivityLow: '낮음 (오탐 적음)',
+10
View File
@@ -2193,6 +2193,8 @@ export default {
slicerCard: 'Fatiador',
orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
bambuStudioApiUrl: 'Bambu Studio sidecar URL',
slicerStallTimeout: 'Tempo limite de inatividade do fatiador (minutos)',
slicerStallTimeoutDescription: 'Desiste de um fatiamento apos esse tempo sem progresso do sidecar. Modelos pesados que continuam relatando progresso nunca sao interrompidos, por mais que demorem. Sidecars que nao relatam progresso usam este valor como limite de tempo total.',
slicerApiUrlDescription: 'URL do contêiner sidecar slicer-API. Deixe em branco para usar SLICER_API_URL / BAMBU_STUDIO_API_URL.',
slicerBundlesRemoved: {
title: 'Bundles do fatiador (removido)',
@@ -2405,6 +2407,8 @@ export default {
autoArchiveDescription: 'Salvar automaticamente arquivos 3MF quando impressões forem concluídas',
saveThumbnailsDescription: 'Extrair e salvar imagens de pré-visualização dos arquivos 3MF',
captureFinishPhotoDescription: 'Tirar foto da câmera da impressora quando a impressão for concluída. Bambuddy grava um timelapse curto durante a impressão para que a foto possa ser obtida do momento antes da mesa descer; o arquivo do timelapse é mantido se você habilitou o timelapse para esta impressão, caso contrário ele é excluído automaticamente após a captura da foto.',
finishPhotoRestorePlate: 'Elevar a mesa para a foto final',
finishPhotoRestorePlateDescription: 'A impressora baixa a mesa cerca de 100 mm ao fim de uma impressão, deixando a peça pronta abaixo do enquadramento da câmera. O Bambuddy a eleva novamente até logo acima da última camada impressa, tira a foto e depois a baixa de novo. Ignorado quando a altura da impressão é desconhecida ou há outro trabalho na fila.',
ffmpegNotInstalled: 'ffmpeg não instalado',
ffmpegRequired: 'A captura de câmera requer ffmpeg. Instale via <brew>brew install ffmpeg</brew> (macOS) ou <apt>apt install ffmpeg</apt> (Linux).',
camera: 'Câmera',
@@ -2829,6 +2833,7 @@ export default {
title: 'Erros - {{name}}',
noErrors: 'Nenhum erro',
viewOnWiki: 'Ver no Bambu Lab Wiki',
mqttVerifyFailedRemedy: 'Ative o Modo Desenvolvedor na impressora, reinicie a impressora e inicie o trabalho novamente.',
unknownCode: 'Código HMS desconhecido — consulte o wiki da Bambu Lab para mais detalhes.',
clearInstructions: 'Limpe os erros na impressora para descartá-los aqui.',
clearErrors: 'Limpar Erros',
@@ -5922,6 +5927,7 @@ export default {
filteringFor: 'Filtrando por: {{material}}',
noKProfile: 'Nenhum perfil K (usar padrão 0.020)',
noMatchingKProfiles: 'Nenhum perfil K correspondente encontrado. O K padrão=0.020 será usado.',
otherKProfiles: 'Outros perfis K nesta impressora',
selectFilamentFirst: 'Selecione um perfil de filamento primeiro',
kFromCalibration: 'K={{value}} da calibração da impressora',
customColorLabel: 'Cor Personalizada (opcional)',
@@ -6530,8 +6536,12 @@ export default {
description: 'Monitora impressões via API ML do Obico auto-hospedada e age automaticamente em falhas detectadas.',
mlUrl: 'URL da API ML do Obico',
mlUrlHint: 'URL base do seu contêiner Obico ml_api auto-hospedado (ex.: http://192.168.1.10:3333).',
mlToken: 'Token da API ML (opcional)',
mlTokenPlaceholder: 'Deixe vazio se o servidor for executado sem ML_API_TOKEN',
mlTokenHint: 'Deve corresponder à variável de ambiente ML_API_TOKEN do seu contêiner Obico ml_api. Deixe vazio se o contêiner for executado sem token.',
test: 'Testar',
testSuccess: 'API ML acessível e operacional.',
testSuccessTokenUnknown: 'API ML acessível e operacional. Não foi possível verificar o token.',
testFailed: 'Não foi possível acessar a API ML.',
sensitivity: 'Sensibilidade',
sensitivityLow: 'Baixa (menos falsos positivos)',
+10
View File
@@ -2111,6 +2111,8 @@ export default {
slicerCard: "Слайсер",
orcaslicerApiUrl: "URL API-службы OrcaSlicer",
bambuStudioApiUrl: "URL API-службы Bambu Studio",
slicerStallTimeout: 'Тайм-аут простоя слайсера (минуты)',
slicerStallTimeoutDescription: 'Прервать нарезку, если sidecar не сообщает о прогрессе в течение этого времени. Тяжёлые модели, которые продолжают сообщать о прогрессе, не прерываются, сколько бы времени ни потребовалось. Для sidecar без отчёта о прогрессе это значение используется как общий лимит времени.',
slicerApiUrlDescription: "URL контейнера API-службы слайсера. Оставьте пустым, чтобы использовать значения переменных окружения SLICER_API_URL или BAMBU_STUDIO_API_URL.",
slicerBundlesRemoved: {
title: "Пакеты профилей слайсера (удалено)",
@@ -2322,6 +2324,8 @@ export default {
autoArchiveDescription: "Автоматически сохранять 3MF после завершения печати",
saveThumbnailsDescription: "Извлекать и сохранять изображения предпросмотра из 3MF",
captureFinishPhotoDescription: "Сделать снимок камерой принтера после завершения печати. Во время печати Bambuddy записывает короткий таймлапс, чтобы получить кадр до опускания стола. Если таймлапс был включён для задания, файл сохранится; иначе после получения снимка он будет автоматически удалён.",
finishPhotoRestorePlate: "Поднимать стол для финального снимка",
finishPhotoRestorePlateDescription: "По окончании печати принтер опускает стол примерно на 100 мм, и готовая модель оказывается ниже кадра камеры. Bambuddy поднимает стол обратно чуть выше последнего напечатанного слоя, делает снимок и снова опускает его. Пропускается, если высота печати неизвестна или в очереди есть другое задание.",
ffmpegNotInstalled: "ffmpeg не установлен",
ffmpegRequired: "Для захвата изображения требуется ffmpeg. Установите его командой <brew>brew install ffmpeg</brew> в macOS или <apt>apt install ffmpeg</apt> в Linux.",
camera: "Камера",
@@ -2683,6 +2687,7 @@ export default {
title: "Ошибки — {{name}}",
noErrors: "Ошибок нет",
viewOnWiki: "Открыть в Bambu Lab Wiki",
mqttVerifyFailedRemedy: "Включите режим разработчика на принтере, перезагрузите принтер и запустите задание снова.",
unknownCode: "Неизвестный код HMS — подробности см. в Bambu Lab Wiki.",
clearInstructions: "Устраните ошибки на принтере, чтобы они исчезли из этого списка.",
clearErrors: "Очистить ошибки",
@@ -5611,6 +5616,7 @@ export default {
filteringFor: "Фильтр по материалу: {{material}}",
noKProfile: "Без K-профиля (стандартное значение 0,020)",
noMatchingKProfiles: "Подходящие K-профили не найдены. Будет использовано стандартное значение K=0,020.",
otherKProfiles: "Другие K-профили на этом принтере",
selectFilamentFirst: "Сначала выберите профиль филамента",
kFromCalibration: "K={{value}} из калибровки принтера",
customColorLabel: "Пользовательский цвет (необязательно)",
@@ -6169,8 +6175,12 @@ export default {
description: "Контролируйте печать через собственный сервер Obico ML API и автоматически реагируйте на обнаруженные сбои.",
mlUrl: "URL Obico ML API",
mlUrlHint: "Базовый URL собственного контейнера Obico ml_api, например http://192.168.1.10:3333.",
mlToken: "Токен ML API (необязательно)",
mlTokenPlaceholder: "Оставьте пустым, если сервер работает без ML_API_TOKEN",
mlTokenHint: "Должен совпадать с переменной окружения ML_API_TOKEN вашего контейнера Obico ml_api. Оставьте пустым, если контейнер работает без токена.",
test: "Проверить",
testSuccess: "ML API доступен и работает.",
testSuccessTokenUnknown: "ML API доступен и работает. Проверить токен не удалось.",
testFailed: "Не удалось подключиться к ML API.",
sensitivity: "Чувствительность",
sensitivityLow: "Низкая (меньше ложных срабатываний)",
+10
View File
@@ -2241,6 +2241,8 @@ export default {
slicerCard: 'Dilimleyici',
orcaslicerApiUrl: 'OrcaSlicer yardımcı bileşen URL',
bambuStudioApiUrl: 'Bambu Studio yardımcı bileşen URL',
slicerStallTimeout: 'Dilimleyici duraklama zaman asimi (dakika)',
slicerStallTimeoutDescription: 'Sidecar bu sure boyunca ilerleme bildirmezse dilimleme iptal edilir. Ilerleme bildirmeye devam eden agir modeller ne kadar surerse sursun kesilmez. Ilerleme bildirmeyen sidecar surumleri bu degeri toplam sure siniri olarak kullanir.',
slicerApiUrlDescription: 'Dilimleyici-API yardımcı bileşen konteynerinin URL\'si. SLICER_API_URL / BAMBU_STUDIO_API_URL ortam değişkeni varsayılanlarını kullanmak için boş bırakın.',
slicerBundlesRemoved: {
title: 'Dilimleyici Paketleri (kaldırıldı)',
@@ -2456,6 +2458,8 @@ export default {
autoArchiveDescription: 'Baskılar tamamlandığında 3MF dosyalarını otomatik olarak kaydet',
saveThumbnailsDescription: '3MF dosyalarından önizleme görüntülerini çıkar ve kaydet',
captureFinishPhotoDescription: 'Baskı tamamlandığında yazıcı kamerasından bir fotoğraf çek. Bambuddy, baskı sırasında kısa bir zaman atlamalı kayıt yapar, böylece fotoğraf tabla inmeden önceki andan alınabilir. Bu baskı için zaman atlamalı kaydı etkinleştirdiyseniz dosya saklanır, aksi takdirde fotoğraf çekildikten sonra otomatik olarak silinir.',
finishPhotoRestorePlate: 'Bitiş fotoğrafı için tablayı yükselt',
finishPhotoRestorePlateDescription: 'Yazıcı, baskı bittiğinde tablayı yaklaşık 100 mm aşağı indirir ve tamamlanmış baskı kameranın çerçevesinin altında kalır. Bambuddy tablayı son basılan katmanın hemen üzerine geri kaldırır, fotoğrafı çeker ve ardından tekrar indirir. Baskı yüksekliği bilinmiyorsa veya kuyrukta başka bir iş varsa atlanır.',
ffmpegNotInstalled: 'ffmpeg yüklü değil',
ffmpegRequired: 'Kamera yakalama ffmpeg gerektirir. <brew>brew install ffmpeg</brew> (macOS) veya <apt>apt install ffmpeg</apt> (Linux) ile yükleyin.',
// Kamera
@@ -2845,6 +2849,7 @@ export default {
title: 'Hatalar - {{name}}',
noErrors: 'Hata yok',
viewOnWiki: 'Bambu Lab Wiki\'de görüntüle',
mqttVerifyFailedRemedy: 'Yazicida Gelistirici Modunu etkinlestirin, yaziciyi yeniden baslatin ve isi tekrar baslatin.',
unknownCode: 'Bilinmeyen HMS kodu — ayrıntılar için Bambu Lab wiki sayfasına bakın.',
clearInstructions: 'Buradan kapatmak için yazıcıdaki hataları temizleyin.',
clearErrors: 'Hataları Temizle',
@@ -5878,6 +5883,7 @@ export default {
filteringFor: 'Şu için filtreleniyor: {{material}}',
noKProfile: 'K profili yok (varsayılan 0.020 kullan)',
noMatchingKProfiles: 'Eşleşen K profili bulunamadı. Varsayılan K=0.020 kullanılacak.',
otherKProfiles: 'Bu yazıcıdaki diğer K profilleri',
selectFilamentFirst: 'Önce bir filament profili seçin',
kFromCalibration: 'Yazıcı kalibrasyonundan K={{value}}',
customColorLabel: 'Özel Renk (isteğe bağlı)',
@@ -6481,8 +6487,12 @@ export default {
description: 'Baskıları kendi barındırılan bir Obico ML API ile izle ve algılanan başarısızlıklara otomatik olarak yanıt ver.',
mlUrl: 'Obico ML API URL\'si',
mlUrlHint: 'Kendi barındırılan Obico ml_api konteynerinizin temel URL\'si (örn. http://192.168.1.10:3333).',
mlToken: 'ML API Belirteci (isteğe bağlı)',
mlTokenPlaceholder: 'Sunucu ML_API_TOKEN olmadan çalışıyorsa boş bırakın',
mlTokenHint: 'Obico ml_api konteynerinizin ML_API_TOKEN ortam değişkeniyle eşleşmelidir. Konteyner belirteç olmadan çalışıyorsa boş bırakın.',
test: 'Test',
testSuccess: 'ML API erişilebilir ve sağlıklı.',
testSuccessTokenUnknown: 'ML API erişilebilir ve sağlıklı. Belirteç doğrulanamadı.',
testFailed: 'ML API\'ye erişilemedi.',
sensitivity: 'Hassasiyet',
sensitivityLow: 'Düşük (daha az yanlış pozitif)',
+10
View File
@@ -2256,6 +2256,8 @@ export default {
slicerCard: "Слайсер",
orcaslicerApiUrl: "URL допоміжного сервісу OrcaSlicer",
bambuStudioApiUrl: "URL допоміжного сервісу Bambu Studio",
slicerStallTimeout: 'Тайм-аут простою слайсера (хвилини)',
slicerStallTimeoutDescription: 'Перервати нарізку, якщо sidecar не повідомляє про прогрес протягом цього часу. Важкі моделі, які продовжують повідомляти про прогрес, ніколи не перериваються, скільки б часу не знадобилося. Для sidecar без звіту про прогрес це значення використовується як загальний ліміт часу.',
slicerApiUrlDescription: "URL контейнера допоміжного сервісу slicer-API. Залиште поле порожнім, щоб використовувати типові значення зі змінних середовища SLICER_API_URL / BAMBU_STUDIO_API_URL.",
slicerBundlesRemoved: {
title: "Пакети профілів слайсера (вилучено)",
@@ -2471,6 +2473,8 @@ export default {
autoArchiveDescription: "Автоматично зберігати файли 3MF після завершення друку",
saveThumbnailsDescription: "Витягніть і збережіть зображення попереднього перегляду з файлів 3MF.",
captureFinishPhotoDescription: "Зробіть фотографію з камери принтера після завершення друку. Bambuddy записує короткий проміжок часу під час друку, щоб фотографію можна було отримати з моменту, коли стіл опускається; файл уповільненої зйомки зберігається, якщо ви ввімкнули уповільнену зйомку для цього друку, інакше він автоматично видаляється після зйомки фотографії.",
finishPhotoRestorePlate: "Піднімати стіл для фінального знімка",
finishPhotoRestorePlateDescription: "Після завершення друку принтер опускає стіл приблизно на 100 мм, і готова модель опиняється нижче кадру камери. Bambuddy піднімає стіл назад трохи вище останнього надрукованого шару, робить знімок і знову опускає його. Пропускається, якщо висота друку невідома або в черзі є інше завдання.",
ffmpegNotInstalled: "ffmpeg не встановлено",
ffmpegRequired: "Для зйомки камерою потрібен ffmpeg. Встановіть його за допомогою <brew>brew install ffmpeg</brew> (macOS) або <apt>apt install ffmpeg</apt> (Linux).",
// Camera
@@ -2870,6 +2874,7 @@ export default {
title: "Помилки - {{name}}",
noErrors: "Помилок немає",
viewOnWiki: "Переглянути на Bambu Lab Wiki",
mqttVerifyFailedRemedy: "Увімкніть режим розробника на принтері, перезавантажте принтер і запустіть завдання знову.",
unknownCode: "Невідомий код HMS — подробиці дивіться у вікі Bambu Lab.",
clearInstructions: "Усуньте помилки на принтері, щоб вони зникли тут.",
clearErrors: "Очистити помилки",
@@ -5977,6 +5982,7 @@ export default {
filteringFor: "Фільтрування за: {{material}}",
noKProfile: "Немає профілю K (використовуйте значення за замовчуванням 0,020)",
noMatchingKProfiles: "Не знайдено відповідних K профілів. Використовуватиметься K=0,020 за замовчуванням.",
otherKProfiles: "Інші K-профілі на цьому принтері",
selectFilamentFirst: "Спочатку виберіть профіль філаменту",
kFromCalibration: "K={{value}} від калібрування принтера",
customColorLabel: "Власний колір (необов’язково)",
@@ -6585,8 +6591,12 @@ export default {
description: "Відстежуйте друк за допомогою самостійно розгорнутого Obico ML API та автоматично реагуйте на виявлені помилки.",
mlUrl: "URL Obico ML API",
mlUrlHint: "База URL вашого контейнера ml_api, розміщеного на власному хості Obico (наприклад, http://192.168.1.10:3333).",
mlToken: "Токен ML API (необов'язково)",
mlTokenPlaceholder: "Залиште порожнім, якщо сервер працює без ML_API_TOKEN",
mlTokenHint: "Має збігатися зі змінною середовища ML_API_TOKEN вашого контейнера Obico ml_api. Залиште порожнім, якщо контейнер працює без токена.",
test: "Тест",
testSuccess: "ML API доступний і працює належним чином.",
testSuccessTokenUnknown: "ML API доступний і працює належним чином. Не вдалося перевірити токен.",
testFailed: "Не вдалося підключитися до ML API.",
sensitivity: "Чутливість",
sensitivityLow: "Низький (менше помилкових спрацьовувань)",
+10
View File
@@ -2238,6 +2238,8 @@ export default {
slicerCard: '切片器',
orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
bambuStudioApiUrl: 'Bambu Studio sidecar URL',
slicerStallTimeout: '切片器停滞超时(分钟)',
slicerStallTimeoutDescription: '若 sidecar 在此时长内没有任何进度,则放弃本次切片。持续报告进度的复杂模型无论耗时多久都不会被中断。不报告进度的 sidecar 则将此值作为总时长上限。',
slicerApiUrlDescription: 'slicer-API sidecar 容器的 URL。留空以使用 SLICER_API_URL / BAMBU_STUDIO_API_URL 环境变量默认值。',
slicerBundlesRemoved: {
title: '切片器捆绑包(已移除)',
@@ -2450,6 +2452,8 @@ export default {
autoArchiveDescription: '打印完成时自动保存3MF文件',
saveThumbnailsDescription: '从3MF文件中提取并保存预览图像',
captureFinishPhotoDescription: '打印完成时从打印机摄像头拍照。Bambuddy 会在打印期间录制一段短延时摄影,以便从热床下降前的瞬间获取照片;如果您为本次打印启用了延时摄影,文件将保留,否则会在拍照完成后自动删除。',
finishPhotoRestorePlate: '为完成照片抬升热床',
finishPhotoRestorePlateDescription: '打印结束时打印机会将热床下降约 100 mm,使完成的模型落在相机取景范围之下。Bambuddy 会将热床抬回到最后一层打印高度略上方,拍摄照片后再次下降。若打印高度未知或队列中还有其他任务,则跳过此步骤。',
ffmpegNotInstalled: '未安装ffmpeg',
ffmpegRequired: '摄像头捕获需要ffmpeg。通过 <brew>brew install ffmpeg</brew>(macOS)或 <apt>apt install ffmpeg</apt>(Linux)安装。',
camera: '摄像头',
@@ -2829,6 +2833,7 @@ export default {
title: '错误 - {{name}}',
noErrors: '无错误',
viewOnWiki: '在拓竹 Wiki 上查看',
mqttVerifyFailedRemedy: '在打印机上启用开发者模式,重启打印机,然后重新开始该任务。',
unknownCode: '未知 HMS 代码 — 详情请参阅拓竹 Wiki。',
clearInstructions: '在打印机上清除错误以在此处消除它们。',
clearErrors: '清除错误',
@@ -5921,6 +5926,7 @@ export default {
filteringFor: '筛选:{{material}}',
noKProfile: '无 K 值配置(使用默认值 0.020)',
noMatchingKProfiles: '未找到匹配的 K 值配置。将使用默认 K=0.020。',
otherKProfiles: '此打印机上的其他 K 值配置',
selectFilamentFirst: '请先选择耗材配置',
kFromCalibration: 'K={{value}}(来自打印机校准)',
customColorLabel: '自定义颜色(可选)',
@@ -6529,8 +6535,12 @@ export default {
description: '通过自托管的 Obico ML API 监控打印,并对检测到的故障自动采取行动。',
mlUrl: 'Obico ML API 地址',
mlUrlHint: '您自托管的 Obico ml_api 容器的基础 URL(例如 http://192.168.1.10:3333)。',
mlToken: 'ML API 令牌(可选)',
mlTokenPlaceholder: '如果服务器未设置 ML_API_TOKEN,请留空',
mlTokenHint: '必须与您的 Obico ml_api 容器的 ML_API_TOKEN 环境变量一致。如果容器未使用令牌,请留空。',
test: '测试',
testSuccess: 'ML API 可访问且正常。',
testSuccessTokenUnknown: 'ML API 可访问且正常。无法验证令牌。',
testFailed: '无法访问 ML API。',
sensitivity: '灵敏度',
sensitivityLow: '低(减少误报)',
+10
View File
@@ -2238,6 +2238,8 @@ export default {
slicerCard: '切片器',
orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
bambuStudioApiUrl: 'Bambu Studio sidecar URL',
slicerStallTimeout: '切片器停滯逾時(分鐘)',
slicerStallTimeoutDescription: '若 sidecar 在此時長內沒有任何進度,則放棄本次切片。持續回報進度的複雜模型無論耗時多久都不會被中斷。不回報進度的 sidecar 則將此值作為總時長上限。',
slicerApiUrlDescription: 'slicer-API sidecar 容器的 URL。留空以使用 SLICER_API_URL / BAMBU_STUDIO_API_URL 環境變數預設值。',
slicerBundlesRemoved: {
title: '切片器捆綁包(已移除)',
@@ -2450,6 +2452,8 @@ export default {
autoArchiveDescription: '列印完成時自動儲存3MF檔案',
saveThumbnailsDescription: '從3MF檔案中提取並儲存預覽影像',
captureFinishPhotoDescription: '列印完成時從印表機攝影機拍照。Bambuddy 會在列印期間錄製一段短縮時攝影,以便從熱床下降前的瞬間取得照片;如果您為本次列印啟用了縮時攝影,檔案將保留,否則會在拍照完成後自動刪除。',
finishPhotoRestorePlate: '為完成照片抬升熱床',
finishPhotoRestorePlateDescription: '列印結束時印表機會將熱床下降約 100 mm,使完成的模型落在相機取景範圍之下。Bambuddy 會將熱床抬回到最後一層列印高度略上方,拍攝照片後再次下降。若列印高度未知或佇列中還有其他任務,則跳過此步驟。',
ffmpegNotInstalled: '未安裝ffmpeg',
ffmpegRequired: '攝影機捕獲需要ffmpeg。透過 <brew>brew install ffmpeg</brew>(macOS)或 <apt>apt install ffmpeg</apt>(Linux)安裝。',
camera: '攝影機',
@@ -2829,6 +2833,7 @@ export default {
title: '錯誤 - {{name}}',
noErrors: '無錯誤',
viewOnWiki: '在拓竹 Wiki 上檢視',
mqttVerifyFailedRemedy: '在印表機上啟用開發者模式,重新啟動印表機,然後重新開始該工作。',
unknownCode: '未知 HMS 代碼 — 詳情請參閱拓竹 Wiki。',
clearInstructions: '在印表機上清除錯誤以在此處消除它們。',
clearErrors: '清除錯誤',
@@ -5921,6 +5926,7 @@ export default {
filteringFor: '篩選:{{material}}',
noKProfile: '無 K 值設定(使用預設值 0.020)',
noMatchingKProfiles: '未找到匹配的 K 值設定。將使用預設 K=0.020。',
otherKProfiles: '此印表機上的其他 K 值設定',
selectFilamentFirst: '請先選擇耗材設定',
kFromCalibration: 'K={{value}}(來自印表機校準)',
customColorLabel: '自訂顏色(可選)',
@@ -6529,8 +6535,12 @@ export default {
description: '透過自託管的 Obico ML API 監控列印,並對偵測到的故障自動採取行動。',
mlUrl: 'Obico ML API 地址',
mlUrlHint: '您自託管的 Obico ml_api 容器的基礎 URL(例如 http://192.168.1.10:3333)。',
mlToken: 'ML API 權杖(選填)',
mlTokenPlaceholder: '如果伺服器未設定 ML_API_TOKEN,請留空',
mlTokenHint: '必須與您的 Obico ml_api 容器的 ML_API_TOKEN 環境變數一致。如果容器未使用權杖,請留空。',
test: '測試',
testSuccess: 'ML API 可存取且正常。',
testSuccessTokenUnknown: 'ML API 可存取且正常。無法驗證權杖。',
testFailed: '無法存取 ML API。',
sensitivity: '靈敏度',
sensitivityLow: '低(減少誤報)',
+10 -7
View File
@@ -65,6 +65,7 @@ import { openInSlicer, type SlicerType } from '../utils/slicer';
import { formatDateTime, formatDateOnly, parseUTCDate, type TimeFormat, formatDuration } from '../utils/date';
import { getCurrencySymbol } from '../utils/currency';
import { getBedTypeInfo } from '../utils/bedType';
import { invalidateArchiveAndProjectViews } from '../utils/projectQueries';
import { useIsMobile } from '../hooks/useIsMobile';
import { usePageFileDrop } from '../hooks/usePageFileDrop';
import type { Archive, PrintLogEntry, ProjectListItem } from '../api/client';
@@ -360,7 +361,9 @@ function ArchiveCard({
const deleteMutation = useMutation({
mutationFn: (purgeStats: boolean) => api.deleteArchive(archive.id, purgeStats),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['archives'] });
// A deleted archive leaves its project too, so the project views have to
// be refreshed alongside the archive list (#2731).
invalidateArchiveAndProjectViews(queryClient);
showToast(t('archives.toast.archiveDeleted'));
},
onError: () => {
@@ -385,8 +388,7 @@ function ArchiveCard({
const assignProjectMutation = useMutation({
mutationFn: (projectId: number | null) => api.updateArchive(archive.id, { project_id: projectId }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['archives'] });
queryClient.invalidateQueries({ queryKey: ['projects'] });
invalidateArchiveAndProjectViews(queryClient);
showToast(t('archives.toast.projectUpdated'));
},
onError: () => {
@@ -1761,7 +1763,9 @@ function ArchiveListRow({
const deleteMutation = useMutation({
mutationFn: (purgeStats: boolean) => api.deleteArchive(archive.id, purgeStats),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['archives'] });
// A deleted archive leaves its project too, so the project views have to
// be refreshed alongside the archive list (#2731).
invalidateArchiveAndProjectViews(queryClient);
showToast(t('archives.toast.archiveDeleted'));
},
onError: () => {
@@ -1786,8 +1790,7 @@ function ArchiveListRow({
const assignProjectMutation = useMutation({
mutationFn: (projectId: number | null) => api.updateArchive(archive.id, { project_id: projectId }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['archives'] });
queryClient.invalidateQueries({ queryKey: ['projects'] });
invalidateArchiveAndProjectViews(queryClient);
showToast(t('archives.toast.projectUpdated'));
},
onError: () => {
@@ -2813,7 +2816,7 @@ export function ArchivesPage() {
return ids.length;
},
onSuccess: (count) => {
queryClient.invalidateQueries({ queryKey: ['archives'] });
invalidateArchiveAndProjectViews(queryClient);
setSelectedIds(new Set());
showToast(`${count} archive${count !== 1 ? 's' : ''} deleted`);
},
+49
View File
@@ -959,6 +959,7 @@ export function SettingsPage() {
settings.auto_archive !== localSettings.auto_archive ||
settings.save_thumbnails !== localSettings.save_thumbnails ||
settings.capture_finish_photo !== localSettings.capture_finish_photo ||
(settings.finish_photo_restore_plate ?? true) !== (localSettings.finish_photo_restore_plate ?? true) ||
settings.default_filament_cost !== localSettings.default_filament_cost ||
settings.currency !== localSettings.currency ||
settings.energy_cost_per_kwh !== localSettings.energy_cost_per_kwh ||
@@ -1008,6 +1009,7 @@ export function SettingsPage() {
(settings.open_in_slicer ?? null) !== (localSettings.open_in_slicer ?? null) ||
(settings.use_slicer_api ?? false) !== (localSettings.use_slicer_api ?? false) ||
(settings.orcaslicer_api_url ?? '') !== (localSettings.orcaslicer_api_url ?? '') ||
(settings.slicer_stall_timeout_minutes ?? 15) !== (localSettings.slicer_stall_timeout_minutes ?? 15) ||
(settings.bambu_studio_api_url ?? '') !== (localSettings.bambu_studio_api_url ?? '') ||
settings.prometheus_enabled !== localSettings.prometheus_enabled ||
settings.prometheus_token !== localSettings.prometheus_token ||
@@ -1058,6 +1060,10 @@ export function SettingsPage() {
auto_archive: localSettings.auto_archive,
save_thumbnails: localSettings.save_thumbnails,
capture_finish_photo: localSettings.capture_finish_photo,
// #2547: `?? true` mirrors the toggle's own default, so an install
// whose settings payload predates this field saves what the user is
// actually looking at rather than `undefined`.
finish_photo_restore_plate: localSettings.finish_photo_restore_plate ?? true,
default_filament_cost: localSettings.default_filament_cost,
currency: localSettings.currency,
energy_cost_per_kwh: localSettings.energy_cost_per_kwh,
@@ -1107,6 +1113,7 @@ export function SettingsPage() {
open_in_slicer: localSettings.open_in_slicer,
use_slicer_api: localSettings.use_slicer_api,
orcaslicer_api_url: localSettings.orcaslicer_api_url,
slicer_stall_timeout_minutes: localSettings.slicer_stall_timeout_minutes,
bambu_studio_api_url: localSettings.bambu_studio_api_url,
prometheus_enabled: localSettings.prometheus_enabled,
prometheus_token: localSettings.prometheus_token,
@@ -1897,6 +1904,28 @@ export function SettingsPage() {
<div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
</div>
{/* #2547: only meaningful while finish photos are being taken at
all, so it hangs off the toggle above rather than standing
alone in the list. */}
{localSettings.capture_finish_photo && (
<div className="flex items-center justify-between pl-4 border-l-2 border-bambu-dark-tertiary">
<div>
<p className="text-white">{t('settings.finishPhotoRestorePlate')}</p>
<p className="text-sm text-bambu-gray">
{t('settings.finishPhotoRestorePlateDescription')}
</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={localSettings.finish_photo_restore_plate ?? true}
onChange={(e) => updateSetting('finish_photo_restore_plate', e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
</div>
)}
{localSettings.capture_finish_photo && ffmpegStatus && !ffmpegStatus.installed && (
<div className="flex items-start gap-2 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<AlertTriangle className="w-5 h-5 text-yellow-500 flex-shrink-0 mt-0.5" />
@@ -4862,6 +4891,26 @@ export function SettingsPage() {
</p>
</div>
)}
{(localSettings.use_slicer_api ?? false) && (
<div>
<label className="block text-sm text-bambu-gray mb-1">
{t('settings.slicerStallTimeout')}
</label>
<input
type="number"
min={1}
max={240}
value={localSettings.slicer_stall_timeout_minutes ?? 15}
onChange={(e) =>
updateSetting('slicer_stall_timeout_minutes', Number(e.target.value))
}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
/>
<p className="text-xs text-bambu-gray mt-1">
{t('settings.slicerStallTimeoutDescription')}
</p>
</div>
)}
</CardContent>
</Card>
+39
View File
@@ -0,0 +1,39 @@
import type { QueryClient } from '@tanstack/react-query';
/**
* Every React Query key whose data is derived from which archives belong to a
* project. All are prefixes: `['project']` matches `['project', 42]`, so one
* entry covers every project id currently in the cache.
*/
const PROJECT_VIEW_QUERY_KEYS = [
['projects'],
['project'],
['project-archives'],
['project-timeline'],
['project-file-progress'],
] as const;
/**
* Refresh everything a project shows after an archive is deleted, or moved
* into or out of a project.
*
* The default `staleTime` is 60s, so without this a project page visited
* within a minute of the change serves its cached answer and keeps showing a
* print that is no longer there — the user has to reload by hand (#2731).
* Deletes previously invalidated only `['archives']`, and the project-assign
* mutations only `['projects']`, which refreshed the overview cards but never
* the detail page they were most likely looking at.
*/
export function invalidateProjectViews(queryClient: QueryClient) {
return Promise.all(
PROJECT_VIEW_QUERY_KEYS.map((queryKey) => queryClient.invalidateQueries({ queryKey: [...queryKey] })),
);
}
/** As above, plus the archive list itself — for mutations that change both. */
export function invalidateArchiveAndProjectViews(queryClient: QueryClient) {
return Promise.all([
queryClient.invalidateQueries({ queryKey: ['archives'] }),
invalidateProjectViews(queryClient),
]);
}