Auto-detect subnet for printer discovery

The Add Printer dialog previously required users to manually enter their
network subnet for scanning (defaulting to 192.168.1.0/24). Now the
backend detects available network interfaces and returns their subnets
via the /discovery/info endpoint. The frontend auto-selects the first
detected subnet and shows a dropdown when multiple subnets are available,
falling back to a text input if none are detected.
This commit is contained in:
maziggy
2026-02-08 12:17:20 +01:00
parent 1e15595f45
commit d73da5e0ef
8 changed files with 255 additions and 10 deletions
+1
View File
@@ -13,6 +13,7 @@ All notable changes to Bambuddy will be documented in this file.
- **Spoolman Fill Level for AMS Lite / External Spools** ([#293](https://github.com/maziggy/bambuddy/issues/293)) — AMS Lite (no weight sensor) always reported 0% fill level. Now uses Spoolman's remaining weight as a fallback when AMS reports 0%. External spools also show fill level from Spoolman data. Fill bars and hover cards indicate "(Spoolman)" when the data source is Spoolman rather than AMS.
### Improved
- **Auto-Detect Subnet for Printer Discovery** — Docker users no longer need to manually enter a subnet in the Add Printer dialog. Bambuddy auto-detects available network subnets and pre-selects the first one. When multiple subnets are available (e.g., eth0 + wlan0), a dropdown lets users choose. Falls back to manual text input if no subnets are detected.
- **Japanese Locale Complete Overhaul** — Restructured `ja.ts` from a divergent format (different key structure, 12 structural conflicts, 1,366 missing translations) to match the English/German locale structure exactly. Translated all 2,083 keys into Japanese, achieving full parity with EN/DE. Zero structural divergences, zero missing keys.
### Fixed
+1 -1
View File
@@ -442,7 +442,7 @@ services:
network_mode: host
```
> **Note:** Docker's default bridge networking cannot receive SSDP multicast packets for automatic printer discovery. When using `network_mode: host`, Bambuddy can discover printers via subnet scanning - enter your network range (e.g., `192.168.1.0/24`) in the Add Printer dialog.
> **Note:** Docker's default bridge networking cannot receive SSDP multicast packets for automatic printer discovery. When using `network_mode: host`, Bambuddy auto-detects your network subnet and can discover printers via subnet scanning in the Add Printer dialog.
</details>
+4
View File
@@ -18,6 +18,7 @@ from backend.app.services.discovery import (
is_running_in_docker,
subnet_scanner,
)
from backend.app.services.network_utils import get_network_interfaces
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/discovery", tags=["discovery"])
@@ -35,6 +36,7 @@ class DiscoveryInfo(BaseModel):
is_docker: bool
ssdp_running: bool
scan_running: bool
subnets: list[str] = []
class SubnetScanRequest(BaseModel):
@@ -67,10 +69,12 @@ async def get_discovery_info(
_: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
):
"""Get discovery environment info (Docker detection, etc.)."""
subnets = [iface["subnet"] for iface in get_network_interfaces()]
return DiscoveryInfo(
is_docker=is_running_in_docker(),
ssdp_running=discovery_service.is_running,
scan_running=subnet_scanner.is_running,
subnets=subnets,
)
@@ -25,9 +25,24 @@ class TestDiscoveryAPI:
assert "is_docker" in data
assert "ssdp_running" in data
assert "scan_running" in data
assert "subnets" in data
assert isinstance(data["is_docker"], bool)
assert isinstance(data["ssdp_running"], bool)
assert isinstance(data["scan_running"], bool)
assert isinstance(data["subnets"], list)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_discovery_info_subnets_are_cidr(self, async_client: AsyncClient):
"""Verify subnets are valid CIDR notation strings."""
response = await async_client.get("/api/v1/discovery/info")
assert response.status_code == 200
data = response.json()
for subnet in data["subnets"]:
assert isinstance(subnet, str)
# Should contain a slash for CIDR notation
assert "/" in subnet, f"Subnet {subnet} is not in CIDR notation"
# ========================================================================
# SSDP Discovery endpoints
@@ -140,3 +155,14 @@ class TestDiscoveryService:
assert response1.status_code == 200
assert response2.status_code == 200
assert response1.json()["is_docker"] == response2.json()["is_docker"]
@pytest.mark.asyncio
@pytest.mark.integration
async def test_subnets_consistent_across_calls(self, async_client: AsyncClient):
"""Verify subnet detection returns consistent results."""
response1 = await async_client.get("/api/v1/discovery/info")
response2 = await async_client.get("/api/v1/discovery/info")
assert response1.status_code == 200
assert response2.status_code == 200
assert response1.json()["subnets"] == response2.json()["subnets"]
@@ -0,0 +1,182 @@
/**
* Tests for AddPrinterModal discovery subnet auto-detection.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render } from '../utils';
import { PrintersPage } from '../../pages/PrintersPage';
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';
const mockPrinters = [
{
id: 1,
name: 'X1 Carbon',
ip_address: '192.168.1.100',
serial_number: '00M09A350100001',
access_code: '12345678',
model: 'X1C',
enabled: true,
nozzle_diameter: 0.4,
nozzle_type: 'hardened_steel',
location: null,
auto_archive: true,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
},
];
const mockPrinterStatus = {
connected: true,
state: 'IDLE',
progress: 0,
layer_num: 0,
total_layers: 0,
temperatures: { nozzle: 25, bed: 25, chamber: 25 },
remaining_time: 0,
filename: null,
wifi_signal: -50,
};
describe('AddPrinterModal Discovery', () => {
beforeEach(() => {
server.use(
http.get('/api/v1/printers/', () => {
return HttpResponse.json(mockPrinters);
}),
http.get('/api/v1/printers/:id/status', () => {
return HttpResponse.json(mockPrinterStatus);
}),
http.get('/api/v1/queue/', () => {
return HttpResponse.json([]);
})
);
});
it('auto-populates subnet from discovery info in Docker mode', async () => {
server.use(
http.get('/api/v1/discovery/info', () => {
return HttpResponse.json({
is_docker: true,
ssdp_running: false,
scan_running: false,
subnets: ['10.0.0.0/24'],
});
})
);
render(<PrintersPage />);
// Wait for printer page to load
await waitFor(() => {
expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
});
// Click the Add Printer button
const addButton = screen.getByText(/add printer/i);
await userEvent.click(addButton);
// Wait for the modal and discovery info to load
await waitFor(() => {
// Should show subnet dropdown with detected subnet
const subnetSelect = screen.getByDisplayValue('10.0.0.0/24');
expect(subnetSelect).toBeInTheDocument();
});
});
it('shows dropdown when multiple subnets detected in Docker mode', async () => {
server.use(
http.get('/api/v1/discovery/info', () => {
return HttpResponse.json({
is_docker: true,
ssdp_running: false,
scan_running: false,
subnets: ['192.168.1.0/24', '10.0.0.0/24'],
});
})
);
render(<PrintersPage />);
await waitFor(() => {
expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
});
const addButton = screen.getByText(/add printer/i);
await userEvent.click(addButton);
await waitFor(() => {
// Should show a select element (dropdown) with both subnets
const selectElement = screen.getByDisplayValue('192.168.1.0/24');
expect(selectElement.tagName).toBe('SELECT');
// Both options should be available
const options = selectElement.querySelectorAll('option');
expect(options).toHaveLength(2);
expect(options[0].textContent).toBe('192.168.1.0/24');
expect(options[1].textContent).toBe('10.0.0.0/24');
});
});
it('shows text input when no subnets detected in Docker mode', async () => {
server.use(
http.get('/api/v1/discovery/info', () => {
return HttpResponse.json({
is_docker: true,
ssdp_running: false,
scan_running: false,
subnets: [],
});
})
);
render(<PrintersPage />);
await waitFor(() => {
expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
});
const addButton = screen.getByText(/add printer/i);
await userEvent.click(addButton);
await waitFor(() => {
// Should show a text input with placeholder
const textInput = screen.getByPlaceholderText('192.168.1.0/24');
expect(textInput).toBeInTheDocument();
expect(textInput.tagName).toBe('INPUT');
});
});
it('does not show subnet field in non-Docker mode', async () => {
server.use(
http.get('/api/v1/discovery/info', () => {
return HttpResponse.json({
is_docker: false,
ssdp_running: false,
scan_running: false,
subnets: ['192.168.1.0/24'],
});
})
);
render(<PrintersPage />);
await waitFor(() => {
expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
});
const addButton = screen.getByText(/add printer/i);
await userEvent.click(addButton);
await waitFor(() => {
// Should show the discover button but NOT the subnet field
expect(screen.getByText(/discover printers/i)).toBeInTheDocument();
});
// Subnet field should not exist
expect(screen.queryByPlaceholderText('192.168.1.0/24')).not.toBeInTheDocument();
expect(screen.queryByDisplayValue('192.168.1.0/24')).not.toBeInTheDocument();
});
});
+13
View File
@@ -364,6 +364,19 @@ export const handlers = [
return new HttpResponse(null, { status: 204 });
}),
// ========================================================================
// Discovery
// ========================================================================
http.get('/api/v1/discovery/info', () => {
return HttpResponse.json({
is_docker: false,
ssdp_running: false,
scan_running: false,
subnets: ['192.168.1.0/24'],
});
}),
// ========================================================================
// Version / Health
// ========================================================================
+1
View File
@@ -3934,6 +3934,7 @@ export interface DiscoveryInfo {
is_docker: boolean;
ssdp_running: boolean;
scan_running: boolean;
subnets: string[];
}
export interface SubnetScanStatus {
+27 -9
View File
@@ -3385,13 +3385,18 @@ function AddPrinterModal({
const [discoveryError, setDiscoveryError] = useState('');
const [hasScanned, setHasScanned] = useState(false);
const [isDocker, setIsDocker] = useState(false);
const [subnet, setSubnet] = useState('192.168.1.0/24');
const [detectedSubnets, setDetectedSubnets] = useState<string[]>([]);
const [subnet, setSubnet] = useState('');
const [scanProgress, setScanProgress] = useState({ scanned: 0, total: 0 });
// Fetch discovery info on mount
useEffect(() => {
discoveryApi.getInfo().then(info => {
setIsDocker(info.is_docker);
if (info.subnets.length > 0) {
setDetectedSubnets(info.subnets);
setSubnet(info.subnets[0]);
}
}).catch(() => {
// Ignore errors, assume not Docker
});
@@ -3556,14 +3561,27 @@ function AddPrinterModal({
<label className="block text-sm text-bambu-gray mb-1">
{t('printers.discovery.subnetToScan')}
</label>
<input
type="text"
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 text-sm"
value={subnet}
onChange={(e) => setSubnet(e.target.value)}
placeholder="192.168.1.0/24"
disabled={discovering}
/>
{detectedSubnets.length > 0 ? (
<select
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 text-sm"
value={subnet}
onChange={(e) => setSubnet(e.target.value)}
disabled={discovering}
>
{detectedSubnets.map(s => (
<option key={s} value={s}>{s}</option>
))}
</select>
) : (
<input
type="text"
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 text-sm"
value={subnet}
onChange={(e) => setSubnet(e.target.value)}
placeholder="192.168.1.0/24"
disabled={discovering}
/>
)}
<p className="mt-1 text-xs text-bambu-gray">
{t('printers.discovery.dockerNote')}
</p>